Concept of new cognition elicitation personally thinking
Concept of new cognition elicitation personally thinking

Git for Web Publishing

Frank Siebert
QR Code
Creative Commons Zero

It is time to write the promised documentation for the idee-web-publishing, named such, because it serves the publishing for my German idee-web-site "Idee der eigenen Erkenntnis".

Concept

This publishing grew out of the development I made to get rid of word-press. In its core it stays the same, but it got now much simpler.

The web pages to be published are created via the "Vim Plugin for Web Publishing" 1 .

The web-site folder structure is already shown in that earlier article, but that is not the complete picture of the idee-project folder structure, which is set up as git repository.

But before I dive into these details, let me first give an overview, what this is all about and what the basic idea of the setup is.

Workflow

My previous article described the content writing workflow up to the point, where the new vim command IdeePublish performs the handover to the publishing. This handover is nothing more and nothing less but the copying of the created content into the client git repository workspace.

With the authoring process finished, the publishing process is the next step.

Web-sites usually provide

This content contains information about the article, but is not written by the article author. It needs to be generated during the publishing process.

The publishing itself consists of the following steps in the client git repository:

# git add .
# git commit .

If you are uncertain that everything has been correctly generated, you might now check the result, apply corrections and repeat the two commands. If everything is right, just do:

# git push

Three Git-Repositories

Authoring happens on a workstation, in my case a notebook. But the web-server runs, who would guess it, on a server.

The commit happens in the git-repository on my notebook, and the push transfers the changes in the repository to a remote git-repository set up as server-repository, which has no working directory. This means, it does not replicate the folders, but only in the git data structure.

This, sadly, cannot simply be served as web-content via a web-server. Therefore a third git-repository, a second client repository, is required, which fetches the pushed changes and serves as web-root for the web-server.

I described the setup of this in the article "Replacing WordPress" in chapter "Idee Website Server Setup" 2 , and since this stays unchanged, I see no point in repeating the description here.

Just some words on the conceptional side: The last workflow step, the manual push to the server-git, triggers there a post-receive-hook-function, which invokes a pull in the third git repository serving as www-root.

Client Git Setup

A function is hooked into the pre-commit-hook of the client repository to produce some portal specific content related to the published web-page.

Here we take a look at the relevant folder structure, the setup of the hook and the python implementation which creates that web-site content.

Idee Project Folders

frank@Asimov:~/projects/idee$ tree -d
.
├── author
├── config
│   └── hooks
│       └── samples
├── generator
│   └── __pycache__
├── nginx
├── snippets
├── test
└── website
    ├── archive
    ├── article
    ├── audio
    ├── css
    ├── env
    │   └── bootstrap
    │       └── css
    ├── files
    ├── image
    ├── js
    ├── legal
    ├── MathJax -> SimpleMathJax/resources/MathJax/es5
    ├── pdf
    ├── portal
    ├── qrcode
    ├── SimpleMathJax
    │   └── resources
    │       └── MathJax
    │           └── es5
    │               ├── a11y
    │               ├── adaptors
    │               ├── input
    │               │   ├── mml
    │               │   └── tex
    │               │       └── extensions
    │               ├── output
    │               │   ├── chtml
    │               │   │   └── fonts
    │               │   │       └── woff-v2
    │               │   └── svg
    │               │       └── fonts
    │               ├── sre
    │               │   └── mathmaps
    │               └── ui
    └── sitemap

In the folder author the authored markdown-files are stored.

The folder config contains a bash-script to change the git configuration, the pre-commit-hook and the hook-samples provided by the git-developers.

The folder generator contains the python programs, which generates the additional portal content required when a new or modified article is published.

The folder nginx contains the web-site configuration usually stored in the folder sites-available , which is referenced in that folder via logical link.

The folder snippets contains markdown snippets to be included in articles during the authoring.

The folder test contains some code to test the python code without doing commits.

The folder website contains the web-content and is served by nginx as www-root.

The folder SimpleMathJax is a git-repository cloned from the SimpleMathJax project. For convenience a symbolic link shortens the path to the javascript used from that repository to render formulas into the HTML-pages.

Git Configuration

In folder config a bash file to change the git configuration exists. It configures the location of the git-hooks, which I want to have inside of the git repositories version control. It switches the git-option quotepath of, which was required when I used German mediawiki titles as filename for exported articles I had written in the wiki, It switches the git-option relativPaths of, to simplify me the checks which processing needs to be triggered in the pre-commit-hook.

config/gitconfig

#!/bin/bash

# We develop hooks and want version control for that
git config --local core.hooksPath ./config/hooks

# We want easy reading of German äüö in the file names
git config --local core.quotepath off

# We provide some variable override options in a modified template
# git config --local commit.template ./config/commit-message

# We process the files committed and need absolute file paths from $GIT_DIR
# written into the commit-message
git config --local status.relativePaths false

The configuration finally looks as follows:

.git/config

[core]
    repositoryformatversion = 0
    filemode = true
    bare = false
    logallrefupdates = true
    hooksPath = ./config/hooks
    quotepath = off
[remote "origin"]
    url = ssh://git@sol/home/git/idee.git
    fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
    remote = origin
    merge = refs/heads/master
[status]
    relativePaths = false

Pre-Commit-Hook

The pre-commit-hook is the first hook called, when the user invokes git commit .

config/hooks/pre-commit

#!/bin/bash

git diff-index --name-status HEAD | /usr/bin/python3 generator/pre-commit.py

git status

read -p "Press Enter to continue" </dev/tty

The result of the command git diff-index --name-status HEAD is passed on into stdin of the pre-commit python program. This result contains the staged changes of the git repository. The commit-message is not yet written and any files changed during the processing, like the index page, the rss-feed, the sitemap pages or the archive pages, can still be added to the ongoing commit, which is done in the respective python modules.

The reason to run git status and to wait for Enter to be pressed is that git writes its commit message while the pre-commit-hook still adds new created or modified files to the commit. The to be committed changes in the later shown commit message thus do not show the correct status.

I want to see the correct status before finalizing the commit, and showing it here was the simplest way to get it done. Modifying the generated commit message would be another option and the way to go, if the implementation would be for other users than me alone.

Module pre-commit

The pre-commit module is called from the pre-commit-hook.

generator/pre-commit.py

"""Website Generator - "fs-commit-msg-hook 2.0".

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15

https://wiki.frank-siebert.de/inst/Replacing_Wordpress
https://idee.frank-siebert.de/article/replacing-wordpress.html
... replacing mediawiki

Website Generator uses Beautiful Soup and GIT to manage
publishing.

Trigger Input
-------------
./website/article/*.html

Other Input
-----------
Article related assets in other website subfolders, like
* audio
* file
* image
* js
* pdf
* qrcode
does not trigger the generator to do anything.

Output
------
For de-DE content:
* idee-index.html
* idee-map.xml
* idee-ress.xml
* sitemap.xml

For en-US content:
* concept-index.html
* concept-map.xml
* concept-ress.xml
* sitemap.xml

Website Generator works with Python 3 and up. It works better if lxml
and/or html5lib is installed, as Beautiful Soup states it runs better then.
"""
# Systen Imports
import sys
import re
from pathlib import Path
import getopt
from bs4 import BeautifulSoup
from bs4.builder._htmlparser import HTMLParserTreeBuilder

from article import Article
from sitemap import SiteMap
from archive import Archive
from rssbuilder import RSSBuilder
from idxbuilder import IDXBuilder


def get_article_list():
    """
    Get the list of added and modified articles from stdin.

    The list of article are be sorted by modification date,
    smallest dates first, to make sure the articles show up with newest/latest
    changed articles first.

    For this we need to look into the html. Because we do not want to read the
    files multiple times, we add the respective soup to the list.

    Returns
    -------
        articles: list of Article or None
    """
    articles = []

    # see man git diff --diff-filter for list of filters
    # M - modified
    # A - added
    searchpattern = re.compile(r"^(M|A).*/article/")

    for line in sys.stdin:
        filename = line[2:].strip()
        if searchpattern.match(line):
            filepath = Path(filename)
            with open(filepath, 'r', encoding="utf-8") as htmlfile:
                html_doc = htmlfile.read()
                htmlfile.close()
                builder = HTMLParserTreeBuilder()
                soup = BeautifulSoup(html_doc, builder=builder)
                article = Article(soup)
                articles.append(article)

    articles.sort(key=lambda x: x.get_modified())
    return articles if len(articles) > 0 else None


if __name__ == "__main__":

    HELPTEXT = 'Usage: git diff-index --name-status HEAD | '\
            '/usr/bin/python3 generator/pre-commit.py\n'

    try:
        opts, args = getopt.getopt(sys.argv[1:], "h:", ["help"])

    except getopt.GetoptError:
        print(HELPTEXT)
        sys.exit(2)

    for opt, arg in opts:
        if opt in {"-h", "--help"}:
            print(HELPTEXT)
            sys.exit()

    ARTICLES = get_article_list()

    if ARTICLES:
        SiteMap(ARTICLES).update()
        # Generate Archive
        Archive(ARTICLES).update()
        # Generate RSS feed (bilingual)
        RSSBuilder(ARTICLES).update()
        # Generate Index Pages (English and German Version)
        IDXBuilder(ARTICLES).update()

    sys.exit(0)

This module is called by the pre-commit-hook, which provides the short list of staged changes via stdin. Modified or added articles are read, their beautiful soup created and via Article-instance appended to the articles work list.

This work list then is used to run the update function of the SiteMap, the Archive, the RSSBuilder and the IDXBuilder.

Module article

The module article contains the class Article , which provides access to meta-data and to the HTML-soup created via the BeautifulSoup module.

generator/article.py

"""
The class Article provides access methods to meta data stored in the soup and to
the soup itself.

"""

import re
from gitmsgconstants import GitMsgConstants as gmc

class Article():
    """
    The class Article
    """

    def __init__(self, soup):
        """
        Initialize Article Data
        """
        self.soup = soup

    def get_modified(self):
        """
        Returns:
        --------
        datetime string of last modification in ISO format
        """
        return self.soup.find("meta",
                               attrs={"property":
                                      "article:modified_time"})["content"]

    def get_language(self):
        """
        Returns:
        --------
        language string like 'de-DE'
        """
        return self.soup.find("html")["lang"].strip()

    def get_soup(self):
        """
        Returns:
        --------
        html soup of the article
        """
        return self.soup

    def get_published(self):
        """
        Returns:
        --------
        datetime string of first publishing in ISO format
        """
        return self.soup.find("meta",
                              attrs={"property":
                                     "article:published_time"})["content"][:19]

    def get_author(self):
        """
        Returns:
        --------
        author namen of the article
        """
        return self.soup.find("meta",
                              attrs={"property":
                                     "article:author"})["content"].strip()

    def get_title(self):
        """
        Returns:
        --------
        title of the article
        """
        return self.soup.find("meta", attrs={"property":
                                             "og:title"})["content"].strip()

    def get_urn(self):
        """
        Returns:
        --------
        urn of the article
        """
        return self.soup.find("meta", attrs={"property":
                                             "article:urn"})["content"].strip()

    def get_site(self):
        """
        Returns:
        --------
        site the article belongs to, "Idee" or "Concept"
        """
        return self.soup.find("meta", attrs={"property":
                                             "og:site_name"})["content"].strip()

    def get_abstract(self):
        """
        Returns:
        --------
        the text of the first paragraph, or the first 406 characters of it,
        whichever is shorter.
        """
        text = self.soup.find("p").text.split()  # I forgot why I split
        return " ".join(text)[0:406]

    def prepare_article_tag_for_rss(self):
        """
        Returns:
        --------
        article_tag
        
        For RSS, the urls are changed from relative to absolute.
        """

        article_tag = self.soup.find("article")
        host = gmc.website + "/"

        # RSS is downloaded, there is no use case for relatvie links
        # even if RSS consumer theoritically could compute them
        # to absolute links

        # "../" becomes "https://idee.frank-siebert.de/"
        tags = article_tag.find_all(re.compile(r".*"), attrs={
            "href": re.compile(r"^\.\./")})
        for tag in tags:
            href = tag.attrs["href"]
            href = href.replace("../", host)
            tag.attrs.update({"href": href})

        tags = article_tag.find_all(re.compile(r".*"), attrs={
            "src": re.compile(r"^\.\./")})
        for tag in tags:
            href = tag.attrs["src"]
            href = href.replace("../", host)
            tag.attrs.update({"src": href})

        # "./" becomes "https://idee.frank-siebert.de/article/"
        tags = article_tag.find_all("a", attrs={
            "href": re.compile(r"^\./")})
        for tag in tags:
            href = tag.attrs["href"]
            href = href.replace("./", host + "article/")
            tag.attrs.update({"href": href})

        article_tag.prettify()
        return article_tag

Module gitmsgconstants

The module gitmsgconstants defines some constants used in the different generator modules.

generator/gitmsgconstants

"""
GitMsgConstants provides project wide constants.

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15

No Instance is required. Could leverage in future a config file.
"""
from pathlib import Path


class GitMsgConstants():
    """
    Dispatch the lines of the git message to registered workers.

    Parameters
    ----------
    gitmessagepath : Path
        Path as type str or type Path pointing to the git message.

    msgworkers : List of MsgWorker
        The list of message workers is used as worker queue. Workers first
        in the queue get their workitems first.

        Workers can return their work result to be picked up by
        later workers.

    Returns
    -------
    GitMsgConstants.

    """

    generator = "pandoc, fs-commit-msg-hook 1.0"
    website = "https://idee.frank-siebert.de"
    pdfimage = "3cd97bab8bb20288768b35fd72979ec3bbf4b2a8.png"

    plainpath = Path("plain")
    confpath = Path("config")
    sitepath = Path("website")
    articlepath = sitepath / "article"
    audiopath = sitepath / "audio"
    csspath = sitepath / "css" / "fs.css"
    headerpath = sitepath / "portal" / "header.html"
    imagepath = sitepath / "image"
    pdfpath = sitepath / "pdf"
    qrpath = sitepath / "qrcode"

    migrationlistpath = confpath / "migrationlist.csv"
    publishingdatapath = sitepath / "pubmetadata.csv"

    pdfdraft = "pdf:draft"
    locale = "og:locale"

    archivepath = sitepath / "archive"
    idee_archive = archivepath / Path("idee-archive.html")
    concept_archive = archivepath / Path("concept-archive.html")
    sitemap = sitepath / Path("sitemap.xml")
    idee_map = sitepath / Path("idee-map.xml")
    concept_map = sitepath / Path("concept-map.xml")
    sitemappath = sitepath / "sitemap"
    map_template = sitepath / "portal" / "monthly-map.xml"
    archive_template = sitepath / "portal" / "monthly-archive.html"

    idee_rss = sitepath / Path("idee-rss.xml")
    concept_rss = sitepath / Path("concept-rss.xml")

    idee_index = sitepath / Path("idee-index.html")
    concept_index = sitepath / Path("concept-index.html")


if __name__ == "__main__":
    pass

Module sitemap

The sitemap module creates and updates sitemap xml-files.

generator/sitemap.py

"""
Update the sitemap of the webseite.

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15

@author: Frank Siebert
"""
import re
import datetime
import subprocess

from bs4 import BeautifulSoup
from bs4.builder._lxml import LXMLTreeBuilderForXML
from gitmsgconstants import GitMsgConstants as gmc

URLSET_TAG = "urlset"
URL_TAG = "url"
LOC_TAG = "loc"
LASTMOD_TAG = "lastmod"
# Not used:
# CHANGEFREQ_TAG = "changefreq"
# PRIORITY_TAG = "priority"

INDEX_TAG = "sitemapindex"
SIDEMAP_TAG = "sitemap"


class SiteMap():
    """Manage all changes in the sitemaps."""

    def __init__(self, articles):
        """
        Initialize changelists.

        Returns
        -------
        None.

        """
        # map information for German page changes on site "Idee".
        self.de_list = []
        # map information for English page changes on site "Concept".
        self.en_list = []
        # The time of the update
        self._nowdate = datetime.datetime.now().isoformat()

        self._updates = articles

    def update(self):
        """
        Iterate over changes and update respective sitemaps.

        Add the respective sitemaps to their respective change list.
        The information about the changed html pages comes from
        PubMetaData.instance._updates  and
        PubMetaData.instance._deletions  .

        Returns
        -------
        None.

        """
        for article in self._updates:
            creation_month = article.get_published()[0:7]

            site = article.get_site().lower()
            sitemap_path = gmc.sitemappath / f"{site}-{creation_month}.xml"

            if site in "idee":
                if sitemap_path not in self.de_list:
                    self.de_list.append(sitemap_path)
            else:
                if sitemap_path not in self.en_list:
                    self.en_list.append(sitemap_path)

            self._update(sitemap_path, article)

        self._update_de()
        self._update_en()
        self._update_main()

    def _update_de(self):
        """Update idee-map.xml."""
        if len(self.de_list) == 0:
            return

        with open(gmc.idee_map, 'r', encoding="utf-8") as sitemap_file:
            xml_doc = sitemap_file.read()
            sitemap_file.flush()
            sitemap_file.close()

        builder = LXMLTreeBuilderForXML
        soup = BeautifulSoup(xml_doc, builder=builder, features='xml')

        for sitemap_path in self.de_list:
            url = gmc.website + "/" + sitemap_path.parts[-2] + \
                "/" + sitemap_path.parts[-1]
            tag = soup.find(LOC_TAG, text=re.compile(r"" + url))

            if not tag:
                tag = soup.find(INDEX_TAG)
                new_tag = soup.new_tag(SIDEMAP_TAG)
                tag.append(new_tag)
                tag = new_tag
                new_tag = soup.new_tag(LOC_TAG)
                new_tag.string = url
                tag.append(new_tag)
                new_tag = soup.new_tag(LASTMOD_TAG)
                tag.append(new_tag)
            else:
                tag = tag.parent

            # tag holds now the correct SIDEMAP_TAG.
            # Either it had been found or created.
            # All used child tags exist also.

            tag = tag.find(LASTMOD_TAG)
            tag.string = self._nowdate

        xml_doc = soup.prettify()

        with open(gmc.idee_map, 'w', encoding="utf-8") as sitemap_file:
            print(xml_doc, file=sitemap_file)
            sitemap_file.flush()
            sitemap_file.close()
            subprocess.run(['git', 'add', gmc.idee_map], check=True)

    def _update_en(self):
        """Update concept-map.xml."""
        if len(self.en_list) == 0:
            return

        with open(gmc.concept_map, 'r', encoding="utf-8") as sitemap_file:
            xml_doc = sitemap_file.read()
            sitemap_file.flush()
            sitemap_file.close()

        builder = LXMLTreeBuilderForXML
        soup = BeautifulSoup(xml_doc, builder=builder, features='xml')

        for sitemap_path in self.en_list:
            url = gmc.website + "/" + sitemap_path.parts[-2] + \
                "/" + sitemap_path.parts[-1]
            tag = soup.find(LOC_TAG, text=re.compile(r"" + url))

            if not tag:
                tag = soup.find(INDEX_TAG)
                new_tag = soup.new_tag(SIDEMAP_TAG)
                tag.append(new_tag)
                tag = new_tag
                new_tag = soup.new_tag(LOC_TAG)
                new_tag.string = url
                tag.append(new_tag)
                new_tag = soup.new_tag(LASTMOD_TAG)
                tag.append(new_tag)
            else:
                tag = tag.parent

            # tag holds now the correct SIDEMAP_TAG.
            # Either it had been found or created.
            # All used child tags exist also.

            tag = tag.find(LASTMOD_TAG)
            tag.string = self._nowdate

        xml_doc = soup.prettify()

        with open(gmc.concept_map, 'w', encoding="utf-8") as sitemap_file:
            print(xml_doc, file=sitemap_file)
            sitemap_file.flush()
            sitemap_file.close()
            subprocess.run(['git', 'add', gmc.concept_map], check=True)

    def _update_main(self):
        """Update sitemap.xml."""
        if len(self.de_list) == 0 and len(self.en_list) == 0:
            return

        with open(gmc.sitemap, 'r', encoding="utf-8") as sitemap_file:
            xml_doc = sitemap_file.read()
            sitemap_file.flush()
            sitemap_file.close()

        builder = LXMLTreeBuilderForXML
        soup = BeautifulSoup(xml_doc, builder=builder, features='xml')

        if len(self.de_list) > 0:
            url = gmc.website + "/" + gmc.idee_map.name
            tag = soup.find(LOC_TAG, text=re.compile(r"" + url))
            # We know in this case, that the tag exists
            tag = tag.parent
            tag = tag.find(LASTMOD_TAG)
            tag.string = self._nowdate

        if len(self.en_list) > 0:
            url = gmc.website + "/" + gmc.concept_map.name
            tag = soup.find(LOC_TAG, text=re.compile(r"" + url))
            # We know in this case, that the tag exists
            tag = tag.parent
            tag = tag.find(LASTMOD_TAG)
            tag.string = self._nowdate

        xml_doc = soup.prettify()

        with open(gmc.sitemap, 'w', encoding="utf-8") as sitemap_file:
            print(xml_doc, file=sitemap_file)
            sitemap_file.flush()
            sitemap_file.close()
            subprocess.run(['git', 'add', gmc.sitemap], check=True)

    @staticmethod
    def _update(sitemap_path, article):
        sitemap_path.resolve()
        if sitemap_path.exists():
            with open(sitemap_path, 'r', encoding="utf-8") as sitemap_file:
                xml_doc = sitemap_file.read()
                sitemap_file.flush()
                sitemap_file.close()
        else:
            gmc.map_template.resolve()
            with open(gmc.map_template, 'r', encoding="utf-8") as sitemap_file:
                xml_doc = sitemap_file.read()
                sitemap_file.flush()
                sitemap_file.close()

        builder = LXMLTreeBuilderForXML
        soup = BeautifulSoup(xml_doc, builder=builder, features='xml')


        urn = article.get_urn()
        article_url = gmc.website + f"/article/{urn}.html"

        tag = soup.find(LOC_TAG, text=re.compile(r"" + article_url))
        if not tag:
            tag = soup.find(URLSET_TAG)
            new_tag = soup.new_tag(URL_TAG)
            tag.append(new_tag)
            tag = new_tag
            new_tag = soup.new_tag(LOC_TAG)
            new_tag.string = article_url
            tag.append(new_tag)
            new_tag = soup.new_tag(LASTMOD_TAG)
            tag.append(new_tag)
        else:
            tag = tag.parent

        # tag holds now the correct URL_TAG.
        # Either it had been found or created.
        # All used child tags exist also.

        tag = tag.find(LASTMOD_TAG)
        tag.string = article.get_modified()

        xml_doc = soup.prettify()

        with open(sitemap_path, 'w', encoding="utf-8") as sitemap_file:
            print(xml_doc, file=sitemap_file)
            sitemap_file.flush()
            sitemap_file.close()
            subprocess.run(['git', 'add', sitemap_path], check=True)

Sitemap Template

The module sitemap uses a template to create new sitemap xml files as needed.

website/portal/monthly-map.xml

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

</urlset>

Module archive

The module archive creates or updates archive files, one per month.

generator/archive.py

"""
Update the archive of the webseite.

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15
"""
import re
import datetime
import subprocess

from bs4 import BeautifulSoup
from bs4 import Comment
from bs4.builder._htmlparser import HTMLParserTreeBuilder
from gitmsgconstants import GitMsgConstants as gmc

class Archive():
    """Manage all changees in the archive."""

    def __init__(self, articles):
        """
        Initialize changelists.

        Returns
        -------
        None.

        """
        # map information for German page changes on site "Idee".
        self.de_list = []
        # map information for English page changes on site "Concept".
        self.en_list = []
        # The time of the update
        self._nowdate = datetime.datetime.now().isoformat()

        self._updates = articles

    def update(self):
        """
        Iterate over changes and update respective archive pages.

        Add the archive pages to their respective change list.

        Returns
        -------
        None.

        """
        for article in self._updates:
            creation_month = article.get_published()[0:7]
            site = article.get_site().lower()
            archive_path = gmc.archivepath / f"{site}-{creation_month}.html"

            if site in "idee":
                if archive_path not in self.de_list:
                    self.de_list.append(archive_path)
            else:
                if archive_path not in self.en_list:
                    self.en_list.append(archive_path)

            soup = self._update(archive_path, article)
            html_doc = soup.prettify()

            with open(archive_path, 'w', encoding="utf-8") as archive_file:
                print(html_doc, file=archive_file)
                archive_file.flush()
                archive_file.close()
                subprocess.run(['git', 'add', archive_path], check=True)

        self._update_de()
        self._update_en()

    def _update_de(self):
        """Update idee-archive.html."""
        if len(self.de_list) == 0:
            return

        with open(gmc.idee_archive, 'r', encoding="utf-8") as archive_file:
            html_doc = archive_file.read()
            archive_file.flush()
            archive_file.close()

        builder = HTMLParserTreeBuilder
        soup = BeautifulSoup(html_doc, builder=builder)

        for archive_path in self.de_list:
            url = './' + archive_path.name
            tag = soup.find("a", href=re.compile(r"" + url))

            if not tag:
                tag = soup.find("main")
                new_tag = soup.new_tag("h3")
                tag.insert(0, new_tag)
                tag = new_tag
                new_tag = soup.new_tag("a")
                new_tag.attrs.update({"href": url})
                new_tag.string = archive_path.name
                tag.append(new_tag)

        html_doc = soup.prettify()

        with open(gmc.idee_archive, 'w', encoding="utf-8") as archive_file:
            print(html_doc, file=archive_file)
            archive_file.flush()
            archive_file.close()
            subprocess.run(['git', 'add', gmc.idee_archive], check=True)

    def _update_en(self):
        """Update concept-archive.html."""
        if len(self.en_list) == 0:
            return

        with open(gmc.concept_archive, 'r', encoding="utf-8") as archive_file:
            html_doc = archive_file.read()
            archive_file.flush()
            archive_file.close()

        builder = HTMLParserTreeBuilder
        soup = BeautifulSoup(html_doc, builder=builder)

        for archive_path in self.en_list:
            url = './' + archive_path.name
            tag = soup.find("a", href=re.compile(r"" + url))

            if not tag:
                tag = soup.find("main")
                new_tag = soup.new_tag("h3")
                tag.insert(0, new_tag)
                tag = new_tag
                new_tag = soup.new_tag("a")
                new_tag.attrs.update({"href": url})
                new_tag.string = archive_path.name
                tag.append(new_tag)

        html_doc = soup.prettify()

        with open(gmc.concept_archive, 'w', encoding="utf-8") as archive_file:
            print(html_doc, file=archive_file)
            archive_file.flush()
            archive_file.close()
            subprocess.run(['git', 'add', gmc.concept_archive], check=True)

    @staticmethod
    def _update(archive_path, article, article_loc="../article/"):
        is_new = None
        archive_path.resolve()
        if archive_path.exists():
            with open(archive_path, 'r', encoding="utf-8") as archive_file:
                html_doc = archive_file.read()
                archive_file.flush()
                archive_file.close()
                is_new = False
        else:
            gmc.archive_template.resolve()
            with open(gmc.archive_template, 'r', encoding="utf-8") as archive_file:
                html_doc = archive_file.read()
                archive_file.flush()
                archive_file.close()
                is_new = True

        builder = HTMLParserTreeBuilder
        soup = BeautifulSoup(html_doc, builder=builder)

        if is_new:
            tag = soup.find("body")
            # SSI header injection is a function of the language
            if "idee-" in str(archive_path):
                new_tag = Comment('# include file="/portal/idee-header.html" ')
                language = "de"
                site_name = "Idee"
                title_prefix = "Archiv"
            else:
                new_tag = Comment(
                    '# include file="/portal/concept-header.html" ')
                language = "en"
                site_name = "Concept"
                title_prefix = "Archive"
            tag.insert(0, new_tag)

            tag = soup.find("html")
            tag.attrs.update({"lang": language, "xml:lang": language})
            tag = soup.find("meta", property="og:site_name")
            tag.attrs.update({"Content": site_name})

            tag = soup.find("title")
            pubdate = article.get_published()[0:7]
            tag.string = f"{title_prefix} {pubdate}"

            tag = soup.find("h1")
            tag.string = f"{title_prefix} {pubdate}"

        urn = article.get_urn()
        article_url = article_loc + f"{urn}.html"

        tag = soup.find("a", href=article_url)
        if not tag:
            tag = soup.find("h1")

            new_tag = soup.new_tag("article")
            if tag:  # true in archive, false in index page
                tag.insert_after(new_tag)
            else:
                tag = soup.find("main")
                tag.insert(0, new_tag)
            tag = new_tag

            new_tag = soup.new_tag("header")
            tag.append(new_tag)
            tag = new_tag

            new_tag = soup.new_tag("h2")
            tag.append(new_tag)
            tag = new_tag

            new_tag = soup.new_tag("a")
            new_tag.attrs.update({"href": article_url})
            new_tag.string = article.get_title()
            tag.append(new_tag)
            tag = tag.parent  # header

            new_tag = soup.new_tag("div")
            tag.append(new_tag)
            tag = new_tag

            pubtime = article.get_published()
            new_tag = soup.new_tag("time")
            new_tag.attrs.update({"datetime": pubtime,
                                  "pubdate": "true"})
            new_tag.string = pubtime[:10]
            tag.append(new_tag)

            new_tag = soup.new_tag("address")
            new_tag.string = article.get_author()
            tag.append(new_tag)
            tag = tag.parent.parent  # article

            new_tag = soup.new_tag("p")
            tag.append(new_tag)
            tag = new_tag

            new_tag = soup.new_tag("a")
            new_tag.attrs.update({"href": article_url})
            new_tag.string = "..."
            tag.append("placeholder")  # for the article abstract
            tag.append(new_tag)
            tag = tag.parent  # article

            new_tag = soup.new_tag("hr")
            tag.append(new_tag)
        else:
            tag = tag.parent.parent.parent  # article

        # tag holds now the article tag.
        # Either it had been found or created.
        # All used child tags exist also.

        # Write or update the article abstract
        tag = tag.find("p")
        tag = tag.find("a")
        tag.previousSibling.replace_with(article.get_abstract())

        # We give every anchor a tabindex
        # 5 Tabindexes are in the portal header
        index = 6
        tags = soup.find_all(re.compile(r"^a$|^audio$|^input$"))
        for tag in tags:
            tag.attrs.update({"tabindex": index})
            index += 1

        return soup

Archive Template

The module archive uses a template to create new monthly archive pages as needed.

website/portal/monthly-archive.html

<!DOCTYPE html>
<html lang="de-DE" xml:lang="de-DE" xmlns="http://www.w3.org/1999/xhtml">
 <head>
  <meta charset="utf-8"/>
  <meta content="pandoc, fs-commit-msg-hook 1.0" name="generator"/>
  <meta content="noindex" name="robots">
  <meta content="width=device-width, initial-scale=1.0, user-scalable=yes" name="viewport"/>
  <meta content="Idee" property="og:site_name"/>
  <link href="../css/fs.css" rel="stylesheet"/>
  <link href="../idee-rss.xml" rel="alternate" type="application/rss+xml" title="RSS"/>
  <link href="../image/favicon.ico" rel="icon" type="image/x-icon"/>
  <title>
  Archive 2021-01
  </title>
 </head>
 <body>
  <main>
   <h1>
    Archive xyz
   </h1>
  </main>
 </body>
</html>

Module rssbuilder

The module rssbuilder creates the rss files for english and german content.

generator/rssbuilder.py

"""
Update the rss feed of the webseite.

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15

All links provided relative to the /article/ folder

@author: Frank Siebert
"""
import re
import datetime
import subprocess
import xml.dom.minidom
from bs4 import BeautifulSoup
from bs4.builder._htmlparser import HTMLParserTreeBuilder
from gitmsgconstants import GitMsgConstants as gmc

CHANNEL_TAG = "channel"
LASTBUILD_TAG = "lastBuildDate"
ITEM_TAG = "item"
TITLE_TAG = "title"
LINK_TAG = "link"
PUBDATE_TAG = "pubDate"
GUID_TAG = "guid"
DESCRIPTION_TAG = "description"
CONTENT_TAG = "content:encoded"
ENCLOSURE_TAG = "enclosure"
AUDIO_TAG = "audio"

# for the testing on server sol
# HOST = "http://sol:88/"
# for the website
HOST = gmc.website + "/"

# Number of items to included into the RSS feed
ITEM_COUNT = 15


class RSSBuilder():
    """Manage all changees in the sitemaps."""

    def __init__(self, articles):
        """
        Initialize changelists.

        Returns
        -------
        None.

        """
        # information for German page changes on site "Idee".
        self.de_list = []
        # information for English page changes on site "Concept".
        self.en_list = []
        # The time of the update
        self._nowdate = datetime.datetime.now().isoformat()
        # soup of currently processed RSS xml
        self._rss_xml = None
        # soup tag of currently processed article
        self._article_tag = None

        for article in articles:
            site = article.get_site().lower()
            if site in "idee":
                self.de_list.append(article)
            else:
                self.en_list.append(article)

    def update(self):
        """
        Iterate over changes and update respective rss files.

        Returns
        -------
        None.

        """
        # Update idee-rss.xml.
        if len(self.de_list) > 0:
            self._update(self.de_list, gmc.idee_rss)

        # Update concept-rss.xml.
        if len(self.en_list) > 0:
            self._update(self.en_list, gmc.concept_rss)


    def _article_cleanup(self):
        """
        Remove some things from the articles BeautifulSoup model.

        Remove those things, which are not rendered nicely in the
        RSS feed consumer, or which are simply dysfunctional there.

        Changes are applied to the currently processed article
        referenced by self._article_tag

        Consumers tested: GPodder, Liferea, Tidings

        Returns
        -------
        None.

        """
        # peel out sections
        sections = self._article_tag.find_all("section")
        for section in sections:
            section.unwrap()

        # fallback to more common tags
        tag = self._article_tag.find("header")
        tag.name = "div"
        self._article_tag.name = "div"

        # Remove toc
        nav = self._article_tag.find("nav")
        if nav:
            nav.decompose()

        # Remove footnote-back anchors.
        tags = self._article_tag.find_all("a", class_="footnote-back")
        for tag in tags:
            tag.decompose()

        # Remove footnote-ref anchors, preserve the footnote.
        tags = self._article_tag.find_all("a", class_="footnote-ref")
        for tag in tags:
            suptag = tag.find("sup")
            # make footnotes more visible
            suptag.string.replace_with("(" + suptag.text + ")")
            tag.unwrap()

        # Remove category anchors
        tags = self._article_tag.find_all("a", class_="category")
        for tag in tags:
            tag.decompose()

        # Remove attributes from image preventiong it
        # to be shown in gpodder
        images = self._article_tag.find_all("img")
        for img in images:
            img.attrs = {"src": img.attrs["src"]}

        # Remove id attributes or some tags might not
        # render nicely
        idtags = self._article_tag.find_all(re.compile(r".*"), attrs={
            "id": True})
        for tag in idtags:
            tag.attrs.pop("id")

        # Remove role attributes or some tags might not
        # render nicely
        idtags = self._article_tag.find_all(re.compile(r".*"), attrs={
            "role": True})
        for tag in idtags:
            tag.attrs.pop("role")

        # Remove tabindex attributes not working anyhow in gpodder
        idtags = self._article_tag.find_all(re.compile(r".*"), attrs={
            "tabindex": True})
        for tag in idtags:
            tag.attrs.pop("tabindex")

    def _get_item_tag(self, channel_tag, url, article):
        """
        Find the item tag based on the url information.

        Parameters
        ----------
        channel_tag : xml.dom.minidom.Tag
            The <channel> tag from the minidom document model.
        url : Str
            The url of the article, whose item tag is to be returned.
        article_data : Dict
            Data dictionary of the currently processed article.

        Returns
        -------
        item_tag : xml.dom.minidom.Tag
            The pre-existing or created <item> tag for the currently
            processed article.
        """
        item_tag = None
        tag = None
        links = channel_tag.getElementsByTagName(LINK_TAG)

        for link in links:
            savedurl = None
            if len(link.childNodes) > 0:
                savedurl = link.childNodes[0].data.strip()
            if url == savedurl:
                tag = link
                break

        if tag:
            item_tag = tag.parentNode
        else:
            item_tag = self._rss_xml.createElement(ITEM_TAG)

            new_tag = self._rss_xml.createElement(TITLE_TAG)
            nodetext = article.get_title()
            textnode = self._rss_xml.createTextNode(nodetext)
            new_tag.appendChild(textnode)
            item_tag.appendChild(new_tag)

            new_tag = self._rss_xml.createElement(LINK_TAG)
            nodetext = url
            textnode = self._rss_xml.createTextNode(nodetext)
            new_tag.appendChild(textnode)
            item_tag.appendChild(new_tag)

            new_tag = self._rss_xml.createElement(PUBDATE_TAG)
            pubdatetime = datetime.datetime.fromisoformat(
                    article.get_published())
            # running your computer on an english locale
            # is helpful for the next line.
            nodetext = pubdatetime.strftime(
                    "%a, %d %b %Y %H:%M:%S +0000")
            textnode = self._rss_xml.createTextNode(nodetext)
            new_tag.appendChild(textnode)
            item_tag.appendChild(new_tag)

            new_tag = self._rss_xml.createElement(GUID_TAG)
            new_tag.setAttribute("isPermaLink", "false")
            item_tag.appendChild(new_tag)

            new_tag = self._rss_xml.createElement(DESCRIPTION_TAG)
            item_tag.appendChild(new_tag)

            new_tag = self._rss_xml.createElement(CONTENT_TAG)
            item_tag.appendChild(new_tag)

        # Processing oldes first, and inserting the items always
        # before the frst childNode, wie get newest first in the XML.
        # To become the sepcification compliant, we finalize by moving
        # all item tags to the end of the channel tag later.
        channel_tag.insertBefore(item_tag,
                                 channel_tag.childNodes[0])

        return item_tag

    def _finalize_channel(self, channel_tag):
        """
        Move the items behind the other channel tags.

        Take care that the number of items does not exceed ITEM_COUNT.
        Update the lastBuildDate.

        Parameters
        ----------
        channel_tag : xml.dom.minidom.Tag
            The <channel> tag from the minidom document model.

        Returns
        -------
        None.

        """
        tags = channel_tag.getElementsByTagName(ITEM_TAG)
        item_count = 0
        for tag in tags:
            if item_count < ITEM_COUNT:
                channel_tag.appendChild(tag)
                item_count += 1
            else:
                channel_tag.removeChild(tag)

        # change last build date
        # running your computer on an english locale
        # is helpful for this.
        tag = channel_tag.getElementsByTagName(LASTBUILD_TAG)[0]
        pubdatetime = datetime.datetime.fromisoformat(
                self._nowdate)
        nodetext = pubdatetime.strftime(
                "%a, %d %b %Y %H:%M:%S +0000")
        tag.childNodes[0].nodeValue = nodetext

    @staticmethod
    def _remove_empty_lines(xml_doc):
        """Remove empty lines with and without whitespaces."""
        pattern = re.compile(r"^\s*$", re.MULTILINE)
        xml_doc = pattern.sub("", xml_doc)
        pattern = re.compile(r"\n\n", re.MULTILINE)
        xml_doc = pattern.sub("\n", xml_doc)
        return xml_doc

    def _update(self, article_list, rss_path):
        """
        Update the RSS file based on the list of changed or added articles.

        Parameters
        ----------
        article_list : List
            The list of article_data entries of changed or added articles.
            Oldest posts are first in the list.
        rss_path : Path
            The Path to the RSS file.

        Returns
        -------
        None.

        """
        with open(rss_path, 'r', encoding="utf-8") as rss_file:
            self._rss_xml = xml.dom.minidom.parse(rss_file)
        channel_tag = self._rss_xml.getElementsByTagName(CHANNEL_TAG)[0]

        for article in article_list:
            self._article_tag = article.prepare_article_tag_for_rss()

            url = f"{HOST}article/{article.get_urn()}.html"
            item_tag = self._get_item_tag(channel_tag, url, article)

            tag = item_tag.getElementsByTagName(GUID_TAG)[0]
            # Changing the guid on update creates problems with some
            # consumers
            nodetext = url  # + "-" + self._nowdate
            if not tag.hasChildNodes():
                textnode = self._rss_xml.createTextNode(nodetext)
                tag.appendChild(textnode)
            else:
                tag.childNodes[0].nodeValue = nodetext

            tag = item_tag.getElementsByTagName(DESCRIPTION_TAG)[0]
            nodetext = article.get_abstract() + " ..."
            if not tag.hasChildNodes():
                textnode = self._rss_xml.createCDATASection(nodetext)
                tag.appendChild(textnode)
            else:
                tag.childNodes[0].nodeValue = nodetext

            # save the audio uri before the removal
            # of the header tag
            url = None
            tag = self._article_tag.find(AUDIO_TAG)
            if tag:
                url = tag.attrs["src"]

            self._article_cleanup()

            tag = item_tag.getElementsByTagName(CONTENT_TAG)[0]
            if tag.hasChildNodes():
                tag.removeChild(tag.childNodes[0])
            nodetext = self._article_tag.prettify()
            nodetext = " ".join(nodetext.split())
            # <div style="page-break-before: always;"> </div>
            # inserted in some content to have nice page-breaks in the PDF
            # might be seen as security risk by some consumers
            nodetext = nodetext.replace(
                    '<div style="page-break-before: always;"> </div>',
                    ''
                    )
            textnode = self._rss_xml.createCDATASection(nodetext)
            tag.appendChild(textnode)

            # An update might add or update the audio
            tags = item_tag.getElementsByTagName(ENCLOSURE_TAG)
            tag = None
            if url and len(tags) == 0:
                tag = self._rss_xml.createElement(ENCLOSURE_TAG)
                item_tag.appendChild(tag)
            elif len(tags) > 0 and not url:
                item_tag.removeChild(tags[0])

            # Update enclosure tag
            if tag:
                audio = gmc.audiopath / f"{article.get_urn()}.mp3"
                filelength = 0
                audio.resolve()
                if audio.exists():
                    filelength = audio.stat().st_size
                tag.setAttribute("url", url)
                tag.setAttribute("length", f"{filelength}")
                tag.setAttribute("type", "audio/mpeg")

        self._finalize_channel(channel_tag)

        xml_doc = self._rss_xml.toprettyxml(indent=" ", encoding="utf-8")
        xml_doc = self._remove_empty_lines(xml_doc.decode("utf-8"))

        with open(rss_path, 'w', encoding="utf-8") as rss_file:
            print(xml_doc, file=rss_file)
            rss_file.flush()
            rss_file.close()
            subprocess.run(['git', 'add', rss_path], check=True)

Module idxbuilder

The module idxbuilder updates the index files for the English and German web-sites. It's a fixed maximum number of articles featured on these index pages. Old articles containing updates are re-posted in the index to make readers aware of the update or correction.

generator/idxbuilder.py

"""
Update the index pages of the webseite.

@author: Frank Siebert
@license: https://creativecommons.org/publicdomain/zero/1.0/deed.en
@date: 2022-03-15

All links provided relative to the /article/ folder

@author: Frank Siebert
"""
import datetime
import subprocess
from gitmsgconstants import GitMsgConstants as gmc
from archive import Archive

# Number of items to included into the RSS feed
ITEM_COUNT = 15


class IDXBuilder():
    """Manage all changees in the index page."""

    def __init__(self, articles):
        """
        Initialize changelists.

        The information about the changed html pages comes from
        PubMetaData.instance._updates  and
        PubMetaData.instance._deletions  .

        Returns
        -------
        None.

        """
        # information for German page changes on site "Idee".
        self.de_list = []
        # information for English page changes on site "Concept".
        self.en_list = []
        # The time of the update
        self._nowdate = datetime.datetime.now().isoformat()
        # soup of currently processed Index html

        for article in articles:
            site = article.get_site().lower()
            if site in "idee":
                self.de_list.append(article)
            else:
                self.en_list.append(article)


    def update(self):
        """
        Iterate over changes and update respective index pages.

        Returns
        -------
        None.

        """
        for article in self.de_list:
            soup = Archive._update(gmc.idee_index, article,
                                   article_loc="./article/")
            soup = IDXBuilder._limit_entries(soup)
            html_doc = soup.prettify()

            with open(gmc.idee_index, 'w', encoding="utf-8") as index_file:
                print(html_doc, file=index_file)
                index_file.flush()
                index_file.close()
                subprocess.run(['git', 'add', gmc.idee_index], check=True)

        for article_data in self.en_list:
            soup = Archive._update(gmc.concept_index, article_data,
                                   article_loc="./article/")
            soup = IDXBuilder._limit_entries(soup)

            html_doc = soup.prettify()

            with open(gmc.concept_index, 'w', encoding="utf-8") as index_file:
                print(html_doc, file=index_file)
                index_file.flush()
                index_file.close()
                subprocess.run(['git', 'add', gmc.concept_index], check=True)

    @staticmethod
    def _limit_entries(soup):
        tags = soup.find_all("article")
        count = 0
        for tag in tags:
            if count > ITEM_COUNT:
                tag.decompose()
            else:
                count += 1
        return soup

Static Website Content

The static website content has undergone only minor changes since the publishing of "Replacing WordPress" 3 .

The legal pages moved into the folder legal, which enables me to update them without getting them posted as article.

Cascading Stylesheets

The CSS files for HTML and for PDF have slightly changed.

website/css/fs.css

/* ***************************************************************************
 * Frank Siebert's CSS 
 +
 * Licence: CC0 
 * httpx://frank-siebert.de/article/creative-commons-cc0-1-0-universal.html 
 * ***************************************************************************/

:root {  
    /* kind of blue */
    --theme-color: #006080;
    /* black on white */
    --theme-text-color: #000000; 
    /* white background */
    --theme-background-color: #ffffff;
    /* important right border */
    --important-right-color: #ffffff;
    /* note right border */
    --note-right-color: #999999;
    /* quote left border */
    --quote-left-color: #cccccc;
    /* quote background */
    --quote-background-color: #f9f9f9;
    /* for minor meta information */
    --theme-meta-color: #999999;                                   
    /* Arial and Helvetica exist on my Computer */
    /* --theme-font-family: Arial, Helvetica, Verdana, Tahoma, sans-serif; */
    --theme-font-family: Liberation Sans, sans-serif;
    /* One theme font only, based on the theme font-family */
    --theme-font: 16px/1.4 Liberation Sans, sans-serif; 
    /* Improve readability */
    --theme-letter-spacing: normal; /* 0.05em; */
}

html {
    padding: 0px 5px 0px 0px; 
    margin: 0; 
    border: 0; 
    font: var(--theme-font); 
    letter-spacing: var(--theme-letter-spacing);
    background-color: lightgray;
}

body {
    width: 100%; 
    height: 100%; 
    min-width: 280px; 
    max-width:1200px; 
    padding: 0 0 0 0;
    margin-top: 0;
    margin-bottom: 0;
    margin-left:auto;
    margin-right:auto;
    border-right: 1px solid var(--theme-color);
    border-left: 1px solid var(--theme-color);

    color: var(--theme-text-color); 
    background-color: var(--theme-background-color); 
    font-size: 1em; 
    word-wrap: break-word; 
}

/* **************************************************************************
 * keep the two body elements in sync 
 * **************************************************************************/

div.row,
body header, 
body main {   
    min-height: 100px; 
    padding: 5px;

    background-color: var(--theme-background-color); 
    background-repeat: no-repeat; 
    background-position: top center; 
    background-size: auto;
}

body header nav { 
    padding: 0 0 0 0;
    /* background: #ddcc99; */
}

/* **************************************************************************
 * The tag <figure> comes with build in padding,
 * but we have to have the same for the article.
 *
 * These styles keep the respectve block elements horizontally alligned.
 *
 * ====  MEDIA SCREEN Variants ====
 * **************************************************************************/

@media screen and (min-width: 641px) {
    body div div, /* yacy search */
    header figure, 
    header nav, 
    header hr, 
    /* main>h3 is used in the archive.html*/
    main article,
    main>h3 {
        display: block;
        margin: 1em 3em 1em 3em;
        /* border-style: dotted;  
         * border-width: 2px;     */
    }
    main>h1 {
        display: block;
        margin: 0.6em 1.8em 0.6em 1.8em;
    }
    .searchinput {
        max-width: 600px;
    }
}

@media screen and (max-width: 640px) {
    body div div, /* yacy search */
    header figure, 
    header nav, 
    header hr, 
    /* main>h3 is used in the archive.html*/
    main article,
    main>h3  {
        display: block;
        margin: 1em 1em 1em 0;
    }
    main>h1 {
        display: block;
        margin: 0.6em 0.6em 0.6em 0.2em;
    }
    .searchinput {
        max-width: 260px;
    }
} 

/* **************************************************************************
 * ==== END OF MEDIA SCREEN Variants ====
 * **************************************************************************/

/* the main content is the article */
article { 
    display: block; 
}

/* **************************************************************************
/* ==== all about headlines ====
 * **************************************************************************/
/* Ich glaube nicht, dass ich a tags unter die Überschriften legen werde.
 * h1 a, h2 a, h3 a, h4 a, h5 a, h6 a { text-decoration: none; } */
h1, h2, h3, h4, h5, h6  
{ 
    line-height: 1.1;
    margin: 0; 
    padding: 1em 0 0.5em 0; 

    color: var(--theme-color); 
    font-family: var(--theme-font-family); 
    font-weight: bold;  
}

h1      { font-size: 1.8em; }
h2      { font-size: 1.6em; }
h3      { font-size: 1.4em; }
h4      { font-size: 1.2em; }
h5, h6  { font-size: 1em; }

/* Newspaper Style First Letter of First Paragraph Upper-Case */
article>p:first-of-type::first-letter,
hr+p::first-letter,
h2+p::first-letter,
h3+p::first-letter,
h4+p::first-letter { 
    font-family: serif;
    font-size: 1.8em;
    font-weight: bold;
}

/* **************************************************************************
 * ==== Article Header ====
 * - h1 headline
 * - address information
 * - page qr-code
 * - licence information
 * - audio player
 * **************************************************************************/

article header {
    min-height: 0 
}

article header h1 {
    padding: 0 0 0.2em 0; 
}

article header div {
    color: var(--theme-meta-color);
    font-size: 0.8em; 
    padding: 0 0 1em 0; 
}


/* The browser decided, that address gets rendered italic,
 * but we do not want this */
article header time,
article header address {
    padding-right: 20px;
    display: inline;
    font: var(--theme-font);
    font-size:inherit
}

/* **************************************************************************
 * ==== Article Block Elements
 * **************************************************************************/
p {
    margin: 0;
    font-size: 1em;
    padding: 0 0 1em 0;
}

p:last-child
{
    padding-bottom: 0;
}

table {
    padding: 10px 20px 10px 20px;
  overflow-wrap: anywhere;
    display: block;
}

table th {
    background: #ddd; 
    border-right: 1px solid #fff; 
    padding: 10px 20px; 
}

table tr th:last-child {
    border-right: 1px solid #ddd;
}

table td {
    padding: 5px 20px;
    border: 1px solid #ddd;
}

table caption { font: var(--theme-font); font-size: 0.8em; 
color: var(--theme-color); font-style: italic; padding: 2px;
caption-side:bottom; padding: 0 20px 20px 20px }

/* **************************************************************************
 * ==== Figures in the header and in the article ====
 * **************************************************************************/

figure img    { width: 100%; height: auto; }
figure audio  { width: 50%;  height: auto; min-height:2em;}
header figure figcaption  { font: var(--theme-font); font-size: 1em; 
color: var(--theme-color); font-weight: bold}
article figure            { margin: 10px }
figure figcaption { font: var(--theme-font); font-size: 0.8em; 
color: var(--theme-color); font-style: italic; padding: 2px;}

article header div figure           { display: Inline; }
article header div figure img       { width: 50px; }
article header div figure figcaption { display: Inline; width: 150px }
article header div figure audio { margin: .5em .5em .5em .5em; }

/* **************************************************************************
 * ==== Navigation in the header ====
 * **************************************************************************/

header>nav>a {
    font-size: 1.2em;
    padding: 0 0.5em 0 0;
    display: inline-grid;
    grid-template-columns: 30px auto auto auto;
}

header>nav>a>img {
    width: 24px;
    vertical-align: sub; 
}

header>nav>form {
    display: inline;
    padding: 0 0.5em 0 0;
    margin: 0 0 0 0;
}

header>nav>form>input{
    font: var(--theme-font); 
    letter-spacing: var(--theme-letter-spacing);
    font-size: 1em;
    vertical-align: super; 
    padding: 0 0 0 0;
    margin: 0 0 0 0;
    border-color: var(--theme-color);
}

/* context break is meta information */
hr {
    height:1px;
    border-width:0;
    background-color: var(--theme-meta-color);
}

/* **************************************************************************
 *    inline HTML TAGS
 * **************************************************************************/

pre {
    background: #f5f5f5;
    border: 1px solid #ddd;
    padding: 10px;
    text-shadow: 1px 1px rgba(255, 255, 255, 0.4);
    font-size: 0.8em;
    line-height: 1.25;
    margin: 0 0 1em 0;
    overflow: auto;
}

sup, sub { 
    font-size: 0.75em; 
    height: 0;
    line-height: 0;
    position: relative;
    vertical-align: baseline;
}

sup {
    bottom: 1ex; 
}

sub {
    top: 1ex;
}

small { 
    font-size: 0.75em 
}


/* **************************************************************************
 * ==== Navigation and their targets ====
 * **************************************************************************/

*:target {
    border-bottom: 0.3em solid var(--theme-color);
}

a { 
    text-decoration: none;
    font: var(--theme-font); 
    font-size: 1em;
    font-weight: bold;
    color: var(--theme-color); 
    border-width: 0 0 0 0;
    border-style: none;
}
a:link        { color: var(--theme-color);       }
a:visited     { color: var(--theme-text-color);  }

/* figure:has(a:focus), */ /* Wait for CSS 4 */
a:focus,
a:hover /* ,
a:active */ { 
color: var(--theme-background-color); 
background-color: var(--theme-color);
outline: none;
}

figure a:focus,
figure a:hover { 
    color: var(--theme-background-color);
    background-color: var(--theme-color);
    outline: none;
    border: none;
}

header>div>a:focus,
header>div>a:hover {
    background-color: var(--theme-background-color); 
    color: var(--theme-color);
    outline: none;
    border: none;
}

a.category    { visibility:visible } /*  hidden; */


/* **************************************************************************
 * ==== YaCy Search ====
 * **************************************************************************/

p.urlinfo :nth-child(2),
p.urlinfo :nth-child(3),
p.urlinfo :nth-child(4),
p.urlinfo :nth-child(5),
p.urlinfo :nth-child(6),
p.urlinfo :nth-child(7),
.favicon,
.navbar,
.starter-template,
.hidden,
.urlactions,
.input-group-btn,
.sidebar,
#datehistogram,
#api {
    display: none;
}

div {
    min-height: 10px;
    margin: 0 0 0 0;
    padding: 0 0 0 0;
}

span#resNav ul li {
    display: inline;
    font-size: 1.4em;
}

.searchinput {
    font: var(--theme-font); 
    letter-spacing: var(--theme-letter-spacing);
    font-size: 1em;
    border-color: var(--theme-color);
    outline: 5px solid var(--theme-meta-color);
}

.linktitle,
.pagination {
    font-size: 1.4em;
    border-top: 2px solid var(--theme-meta-color);
}

/* **************************************************************************
 * notes (update and correction notes)
 * **************************************************************************/
.important {
  border-right: 2px solid var(--important-right-color);
  margin: 0 2em 0.5em;
  padding: 0.5em 10px;
}

/* **************************************************************************
 * notes (update and correction notes)
 * **************************************************************************/
.note {
  border-right: 2px solid var(--note-right-color);
  margin: 0 2em 0.5em;
  padding: 0.5em 10px;
}

/* **************************************************************************
 * blockquote
 * **************************************************************************/
blockquote {
  font-style:italic;
  background: var(--quote-background-color);
  border-left: 10px solid var(--quote-left-color);
  margin: 0 2em 0.5em;
  padding: 0.5em 10px;
}

/* **************************************************************************
 * ==== syntaxhighlight ====
 * CSS as created in the html style-element by WeasyOrint for syntaxhighlight
 * Changes for the print version need to be applied in fspdf.css
 * Changes for the browser version need to be applied at the end of this file.
 * **************************************************************************/

code{white-space: nowrap;}
span.smallcaps{font-variant: small-caps;}
span.underline{text-decoration: underline;}
div.column{display: inline-block; vertical-align: top; width: 50%;}
div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;}
ul.task-list{list-style: none;}
pre > code.sourceCode { white-space: pre; position: relative; }
pre > code.sourceCode > span { display: inline-block; line-height: 1.25; }
pre > code.sourceCode > span:empty { height: 1.2em; }
code.sourceCode > span { color: inherit; text-decoration: inherit; }
div.sourceCode { margin: 1em 0; }
pre.sourceCode { margin: 0; }

@media screen {
    div.sourceCode { overflow: auto; }
}


@media print {
    pre > code.sourceCode { white-space: pre-wrap; }
    pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; }
}

pre.numberSource code
  { counter-reset: source-line 0; }
pre.numberSource code > span
  { position: relative; left: -4em; counter-increment: source-line; }
pre.numberSource code > span > a:first-child::before
  { content: counter(source-line);
    position: relative; left: -1em; text-align: right; vertical-align: baseline;
    border: none; display: inline-block;
    -webkit-touch-callout: none; -webkit-user-select: none;
    -khtml-user-select: none; -moz-user-select: none;
    -ms-user-select: none; user-select: none;
    padding: 0 4px; width: 4em;
    color: #aaaaaa;
  }
pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; 
    padding-left: 4px; }
div.sourceCode
  {   }

@media screen {
    pre > code.sourceCode > span > a:first-child::before { 
        text-decoration: underline; }
}

code span.al { color: #ff0000; font-weight: bold; } /* Alert */
code span.an { color: #60a0b0; font-weight: bold; font-style: italic; 
    } /* Annotation */
code span.at { color: #7d9029; } /* Attribute */
code span.bn { color: #40a070; } /* BaseN */
code span.bu { } /* BuiltIn */
code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */
code span.ch { color: #4070a0; } /* Char */
code span.cn { color: #880000; } /* Constant */
code span.co { color: #60a0b0; font-style: italic; } /* Comment */
code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; 
    } /* CommentVar */
code span.do { color: #ba2121; font-style: italic; } /* Documentation */
code span.dt { color: #902000; } /* DataType */
code span.dv { color: #40a070; } /* DecVal */
code span.er { color: #ff0000; font-weight: bold; } /* Error */
code span.ex { } /* Extension */
code span.fl { color: #40a070; } /* Float */
code span.fu { color: #06287e; } /* Function */
code span.im { } /* Import */
code span.in { color: #60a0b0; font-weight: bold; font-style: italic; 
    } /* Information */
code span.kw { color: #007020; font-weight: bold; } /* Keyword */
code span.op { color: #666666; } /* Operator */
code span.ot { color: #007020; } /* Other */
code span.pp { color: #bc7a00; } /* Preprocessor */
code span.sc { color: #4070a0; } /* SpecialChar */
code span.ss { color: #bb6688; } /* SpecialString */
code span.st { color: #4070a0; } /* String */
code span.va { color: #19177c; } /* Variable */
code span.vs { color: #4070a0; } /* VerbatimString */
code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; 
    } /* Warning */

/* **************************************************************************
 * ==== syntaxhighlight ====
 * Own Part
 * **************************************************************************/
pre.sourceCode, pre.mysql, pre.nginx {
    width: 90ch; /* classic terminal width for code sections is 80 */
}

website/css/fspdf.css

/* ***************************************************************************
 * Frank Siebert's PDF CSS 
 +
 * Licence: CC0 
 * httpx://frank-siebert.de/article/creative-commons-cc0-1-0-universal.html 
 * ***************************************************************************/

html {
    font-family: Liberation Sans, sans-serif !important;
    font: 12px/1.4 Liberation Sans, sans-serif !important;
    background-color: #ffffff !important;
}

@page {
  size: A4; /* Change from the default size of A4 */
  margin: 1.5cm; /* Set margin on each page */

  @top-right {
    content: counter(page);
    color: #006080;
    font-size: 1.2em;
  }

  @top-left {
    content:  string(pageheader);
    color: #006080;
    font-size: 1.2em;
  }
}

header h1 {
  string-set: pageheader content();
}

article header div figure img       { width: 150px !important; }
article figure a img { width: 70% !important; }

/* **************************************************************************
 * ==== syntaxhighlight ====
 * **************************************************************************/

/* Allow only intentional line breaks in source code */
pre > code.sourceCode > span {
    white-space: pre !important;
}
pre > code.sourceCode > span > span {
    white-space: pre !important;
}

pre.sourceCode, pre.mysql, pre.nginx {
    width: 90ch !important; /* classic terminal width for code sections is 80 */
}

body {
  border: none !important;
}

Server Setup

The server setup has not changed, look into the article "Replacing Wordpress" for the details of the setup.

Footnotes


  1. Vim Plugin for Web Publishing ; Frank Siebert; concept; 2026-03-09

  2. Replacing WordPress - Idee Website Server Setup ; Frank Siebert; Concept; 2022-03-17

  3. Replacing WordPress ; Frank Siebert; Concept; 2022-03-17