Themes erstellen

Alles, was du in FolioPress schreibst — Seiten, Beiträge, Tags — lebt getrennt davon, wie es dargestellt wird. Diese Darstellungsschicht ist ein Theme, und dieser Leitfaden nimmt eines auseinander: den Ordner, die Templates, die Einstellungsdatei und genau die Daten, die jedes Template bekommt. Wer mit HTML und CSS vertraut ist, hat alles, was nötig ist.

Ein Theme ist ein Ordner

Wechsle das Theme, und dieselben Seiten, Beiträge und das Menü werden neu gezeichnet — am Inhalt ändert sich nichts, nur an seiner Kleidung. Ein Theme ist ein Ordner mit drei Dingen: einer Einstellungsdatei, Templates und statischen Dateien.

Ein Theme ist ein Ordner erforderlich optional my-theme/ theme.yml Name, Farben, wie viele Beiträge gelistet werden… templates/ base.html das gemeinsame Gerüst aller Seiten home.html die Startseite page.html eine einzelne Seite (Über uns, Kontakt…) post.html ein einzelner Blogbeitrag tag.html das Archiv eines Tags search.html Suchergebnisse 404.html · 500.html page-<slug>.html ein eigenes Layout für eine Seite partials/ Schnipsel, die andere Templates einbinden locales/pt-PT.json … static/ styles.css · scripts.js favicon.svg · apple-touch-icon.png
Vier Templates und theme.yml genügen; alles andere ist optionaler Feinschliff.

Vier Templates sind Pflicht — base.html, home.html, page.html und post.html —, ein Theme ohne sie lädt nicht. Alles andere ist optional: ein Tag-Archiv, eine Suchseite, eigene Fehlerseiten, ein eigenes Layout für eine bestimmte Seite und ein Ordner locales/ für die Texte des Themes selbst. Themes lassen sich direkt in der Verwaltung unter Themes bearbeiten, als Zip exportieren und in eine andere Website importieren.

Von einer URL zu einer Seite

Es hilft zu sehen, was FolioPress mit einer Anfrage tut, bevor dein Template überhaupt läuft — denn es erklärt, warum Templates so kurz sind.

1 Ein Besucher öffnet eine URL /de-de/ueber-uns 2 FolioPress findet Sprache und Seite de-DE · „Über uns“ 3 Es baut den Kontext page, site_title, languages, navigation… 4 Es wählt ein Template page-ueber-uns.html? sonst page.html 5 Gerendert in base.html HTML geht an den Browser Jeder Wert kommt bereits in der Sprache des Besuchers an; das Template gibt nur aus, was es bekommt.
Eine Anfrage von Anfang bis Ende. In Schritt 3 und 4 leistet das Theme seine Arbeit.

Zwei Dinge fallen auf. Die Sprache wird aus dem URL-Präfix gelesen, sodass /pt-pt/sobre und /about dieselbe Seite in zwei Sprachen sind, und der Kontext, den dein Template bekommt, ist bereits in der richtigen — jeder Titel, Slug, Menüeintrag und Inhalt. Und das Template wird für dich gewählt: Für eine Seite, deren Slug in der Hauptsprache about lautet, sucht FolioPress zuerst page-about.html und fällt auf page.html zurück. Nichts muss verdrahtet werden.

Ein Gerüst, viele Seiten

Templates sind Jinja2 — HTML mit zwei zusätzlichen Klammerarten. {{ page.title }} gibt einen Wert aus. {% if page.poster_image_url %} … {% endif %} ist eine Anweisung: Schleifen, Bedingungen und Vererbung.

base.html das gemeinsame Gerüst aller Seiten <head>, Navigation, Sprachumschalter {% block content %} die eine Lücke, die jedes Template füllt Fußzeile, Skripte Jedes davon füllt die Lücke: home.html {% extends %} page.html {% extends %} post.html {% extends %} tag.html {% extends %} search.html {% extends %}
Kopf- und Fußzeile stehen an einer Stelle; jedes andere Template schreibt nur die Mitte.

base.html enthält alles, was jede Seite teilt — den <head>, die Navigation, den Sprachumschalter, die Fußzeile, die Skripte — mit einer einzigen Lücke in der Mitte. Jedes andere Template beginnt gleich und füllt diese Lücke:

{% extends "base.html" %}

{% block content %}
  <h1>{{ page.title }}</h1>
  {{ page.content | safe }}
{% endblock %}

Alles, was sich über Templates hinweg wiederholt — eine Beitragskarte, eine Seitennavigation — gehört nach partials/ und wird mit {% include "partials/post-card.html" %} eingebunden.

theme.yml: das Einstellungsfeld

Hier beschreibt sich ein Theme selbst und legt ein paar Verhaltensweisen fest, ohne ein Template anzufassen. So nutzen es die mitgelieferten Themes:

title: "Ink"
description: "Ein klares, minimalistisches Theme"
version: "1.0.0"
author: "FolioPress"

meta:
  supports_dark_mode: true
  responsive: true
  categories: [minimal, portfolio]

settings:                    # frei; in Templates als theme_settings verfügbar
  primary_color: "#2563eb"

home:
  posts: 10                  # wie viele Beiträge die Startseite bekommt
  order_by: "-published_at"  # neueste zuerst
  has_pagination: false      # ein Abschnitt, oder echte Seitenlinks?
  image_ratios:
    poster: "16:9"           # was der Bildzuschnitt der Verwaltung hier anbietet
    gallery: "free"

page:  { posts: 10, order_by: "-published_at", has_pagination: false }
post:  { posts: 3,  order_by: "-published_at" }   # verwandte Beiträge unter einem Beitrag
tag:   { posts: 12, order_by: "-published_at", has_pagination: true }

page-about:                  # Überschreibung für eine Seite, per Slug in der Hauptsprache
  image_ratios: { poster: "1:1" }
post-tutorials:              # Überschreibung für Beiträge, deren erster Tag diesen Slug hat
  image_ratios: { poster: "4:3" }
Schlüssel Was er steuert
title, description, version, author Werden auf der Seite Themes der Verwaltung angezeigt.
meta.supports_dark_mode, meta.responsive, meta.categories Beschreibend; für die Theme-Galerie.
meta.social_image Eine Datei im static/ des Themes, 1200x630, als Vorschaubild verwendet, wenn eine Seite kein eigenes Titelbild hat. Ohne sie wird eine solche Seite mit dem 180x180-Icon geteilt.
settings Was immer du möchtest. Wird jedem Template als theme_settings übergeben, sodass eine Farbe oder ein Slogan hier statt im CSS leben kann.
home, page, post, tag Die Beitragsliste, die dieser Template-Typ bekommt: posts (wie viele), order_by, und ob has_pagination echte Seitenlinks ergänzt oder die Liste nur abschneidet.
order_by -published_at (neueste zuerst) oder published_at; ebenso created_at und updated_at, mit oder ohne führendes -; oder random.
image_ratios Was der Bildzuschnitt der Verwaltung beim Bearbeiten dieses Inhaltstyps anbietet: ein Verhältnis wie 16:9, eine Liste davon oder free. Getrennte Werte für poster und gallery.
page-<slug>, post-<tag-slug> Dieselben Schlüssel, die page/post für eine Seite (per Slug in der Hauptsprache) oder für Beiträge überschreiben, deren erster Tag diesen Slug trägt.

Was jedes Template sehen kann

Ein Template holt nie etwas selbst. Es bekommt ein Bündel gebrauchsfertiger Werte — FolioPress nennt es den Kontext — und gibt aus, was es braucht. Ein Teil des Bündels ist auf jeder Seite gleich; der Rest hängt davon ab, welches Template gerendert wird.

Jedes Template bekommt dieses Bündel page site_title site_description navigation_menu languages current_language language_switch_urls footer static_url media_url home_url canonical_url hreflang_urls t() …und je nach Template zusätzlich: home.html posts pages is_home page.html posts is_contact_page post.html related_posts is_post tag.html tag posts search.html search_query search_results results_count
Das gemeinsame Bündel ist auf jeder Seite gleich; die Extras unterscheiden die Template-Typen.

Allen Templates gemeinsam

Variable Was sie ist
page Die angezeigte Seite oder der Beitrag, bereits in der aktuellen Sprache (siehe unten). None im Tag-Archiv und in der Suche.
site_title, site_description, footer Aus den Website-Einstellungen, in der aktuellen Sprache. footer ist bereits aus Markdown gerendert.
navigation_menu Das in der Verwaltung konfigurierte Menü: eine Liste von {title, url, slug, page_id}, jede url fertig für diese Sprache.
languages Die Sprachen der Website, nach Code. Jeder Eintrag: code, name, native_name, short_code, menu_label, flag.
current_language, current_language_code, default_language_code Die gerenderte Sprache (gleiche Form wie ein languages-Eintrag), ihr Code und die Hauptsprache der Website.
language_switch_urls Nach Sprachcode: die URL derselben Seite in dieser Sprache, übersetzter Slug inklusive.
home_url, url_language_code Die Startseite in der aktuellen Sprache (/ oder /pt-pt) und das kleingeschriebene Präfix für jede URL, die du selbst baust ('' für die Hauptsprache).
static_url, media_url Woher die Dateien dieses Themes ausgeliefert werden (/static/<theme>) und wo hochgeladene Bilder liegen.
page_title, meta_description, canonical_url, hreflang_urls, social_image_url, twitter_card_type, json_ld Alles, was der <head> braucht: ein sinnvoller Tab-Titel, absolute kanonische und sprachalternative URLs, ein Open-Graph-Bild und strukturierte Daten — alles vorberechnet.
theme_name, theme_info, theme_settings Der Name des aktiven Themes, seine komplette theme.yml und ihr settings-Block.
t Der Übersetzer des Themes: {{ t('Read more') }} schlägt den Satz in locales/<sprache>.json nach (siehe unten).
request Die FastAPI-Anfrage, für den seltenen Fall, dass du Pfad oder Query-Parameter brauchst.

Das page-Objekt

Dieselbe Form, ob Seite, Beitrag oder Eintrag in einer beliebigen Beitragsliste:

Feld Hinweise
title, slug, url In der aktuellen Sprache. url ist vollständig, inklusive Sprachpräfix oder eigener Domain der Sprache.
content Bereits von Markdown in HTML umgewandelt — ausgeben mit {{ page.content | safe }}.
excerpt, meta_description Kurzformen, in der aktuellen Sprache.
poster_image_url Vollständige URL des Titelbilds, oder None.
gallery, content_gallery Bildlisten: {id, url, title, width, height, original_filename}. image.url direkt verwenden.
tags {slug, title, url} für jeden öffentlichen Tag. Private Tags werden nie aufgenommen.
is_post, is_featured, published_at, created_at, updated_at Ob es ein Beitrag ist, ob hervorgehoben, und die Daten.
id Die Datenbank-ID — praktisch für Anker und Cache-Schlüssel.

Beitragslisten

Template Variable Inhalt
home.html posts (Alias recent_posts), pages, is_home Beiträge, die einen Tag mit der Startseite teilen, in Anzahl und Reihenfolge aus dem home-Abschnitt der theme.yml. Eine Startseite ohne Tags bekommt keine Beiträge — tagge sie, um sie zu füllen.
page.html posts (Alias page_posts) Beiträge, die einen Tag mit dieser Seite teilen, aus dem page-Abschnitt. So listet eine „Blog“-Seite ihre Beiträge.
post.html related_posts, is_post Andere Beiträge, die einen Tag mit diesem teilen, aus dem post-Abschnitt.
tag.html tag (title, slug), posts Jeder veröffentlichte Beitrag mit diesem Tag, aus dem tag-Abschnitt.
search.html search_query, search_results, results_count Der eingegebene Begriff und die passenden Seiten und Beiträge.

Jede Beitragsliste kommt mit einer Paginierung gleicher Form: has_pagination, current_page, total_pages, has_previous, has_next, previous_page, next_page und pagination_urls mit first, previous, next und last — None, wo es nicht weitergeht. Ist `has_pagination` in der `theme.yml` aus, wird die Liste einfach bei `posts` abgeschnitten und die Flags bleiben falsch.

Sprachen und der Umschalter

Zwei der Sprachfelder werden für dich berechnet. short_code ist nur der Sprachcode — EN —, es sei denn, die Website hat auch eine andere Variante derselben Sprache aktiviert; dann behält er seine Region: EN-US und EN-GB. menu_label folgt derselben Regel mit Namen: English, oder English (US) und English (UK). Das ist genau die Regel des Sprachumschalters der Verwaltung, sodass Website und Verwaltung sich nie über eine Beschriftung uneinig sind. Ein vollständiger Umschalter ist deshalb kurz:

{% if languages | length > 1 %}
<button type="button" aria-label="{{ t('Change language') }}">
  {{ current_language.short_code }}
</button>
<ul>
  {% for lang in languages.values() | sort(attribute='menu_label') %}
  <li>
    {% if lang.code == current_language.code %}
      <span aria-current="true">{{ lang.menu_label }}</span>
    {% else %}
      <a href="{{ language_switch_urls[lang.code] }}" hreflang="{{ lang.code }}">{{ lang.menu_label }}</a>
    {% endif %}
  </li>
  {% endfor %}
</ul>
{% endif %}

language_switch_urls zeigt bereits auf dieselbe Seite in jeder Sprache — auf den übersetzten Slug oder, falls diese Übersetzung noch fehlt, auf die Startseite —, sodass ein Umschalter nie selbst eine URL baut.

Die Texte des Themes selbst

Inhalte übersetzt der Autor in der Verwaltung. Die eigenen Texte eines Themes — „Weiterlesen“, „Suche“, „Alle Rechte vorbehalten“ — übersetzt das Theme, in templates/locales/<sprache>.json:

{
  "Read more": "Weiterlesen",
  "Search": "Suche",
  "Change language": "Sprache wechseln"
}

Umschließe den Satz mit {{ t('Read more') }}, und er erscheint in der Sprache des Besuchers. Fehlt ein Satz in der Datei dieser Sprache, fällt er auf die Datei der Hauptsprache zurück, dann auf den Satz selbst — ein halb übersetztes Theme funktioniert also weiter, nur auf Englisch, wo noch keine Übersetzung geschrieben wurde. Die Dateien reisen beim Export mit dem Theme.

Eine Seite mit eigenem Layout

Eine Landingpage, ein Kontaktformular, ein Portfolio-Raster — manchmal verdient eine Seite ein Layout, das keine andere braucht. Lege ein Template namens page-<slug>.html an, wobei <slug> der Slug dieser Seite in der Hauptsprache ist, und FolioPress nutzt es für diese Seite in jeder Sprache. Lösche die Datei, und die Seite kehrt still zu page.html zurück.

{# templates/page-about.html #}
{% extends "base.html" %}
{% block content %}
<section class="about">
  <img src="{{ page.poster_image_url }}" alt="">
  <h1>{{ page.title }}</h1>
  {{ page.content | safe }}
</section>
{% endblock %}

Die Seite, die ein Administrator als Kontaktformular-Seite festlegt, bekommt etwas extra: is_contact_page ist wahr, contact_form_action ist das Ziel des Formulars, und contact_guard_token / contact_honeypot_field sind zwei versteckte Felder, die Bots fernhalten. Nach dem Absenden sagt contact_status, ob ein Dank oder ein Fehler anzuzeigen ist.

Beginne mit einem Theme, das funktioniert

Der schnellste Weg zu einem Theme ist, eines der mitgelieferten zu öffnen — jedes hat eine Live-Demo — und es neben dieser Seite zu lesen. Dupliziere es in der Verwaltung, ändere das CSS, dann die Templates, und du hast etwas Eigenes, ohne je mit einer leeren Datei begonnen zu haben.

Zwei Gewohnheiten, die Zeit sparen. Gib die Werte aus, die der Kontext liefert, statt sie nachzubauen — page.url, tag.url und language_switch_urls kennen Sprachpräfixe und eigene Domains bereits. Und lass das Gerüst in base.html: Wer sich dabei ertappt, einen Header in ein zweites Template zu kopieren, weiß, wohin er gehört.