Creating themes

Everything you write in FolioPress — pages, posts, tags — lives apart from how it is shown. That display layer is a theme, and this guide takes one apart: the folder, the templates, the settings file, and exactly what data each template is handed. If you are comfortable with HTML and CSS you have everything you need.

A theme is a folder

Swap the theme and the same pages, posts and menu are redrawn — nothing about the content changes, only its outfit. A theme is a folder with three things in it: a settings file, templates, and static assets.

A theme is a folder required optional my-theme/ theme.yml name, colours, how many posts to list… templates/ base.html the shared shell every page uses home.html the front page page.html a single page (About, Contact…) post.html a single blog post tag.html a tag archive search.html search results 404.html · 500.html page-<slug>.html a bespoke layout for one page partials/ snippets included from other templates locales/pt-PT.json … static/ styles.css · scripts.js favicon.svg · apple-touch-icon.png
Four templates and theme.yml are all FolioPress needs; everything else is optional polish.

Four templates are required — base.html, home.html, page.html and post.html — and a theme without them will not load. Everything else is optional: a tag archive, a search page, custom error pages, a bespoke layout for one specific page, and a locales/ folder for the theme’s own words. Themes can be edited right in the admin under Themes, exported as a zip, and imported into another site.

From a URL to a page

It helps to see what FolioPress does with a request before your template ever runs, because it explains why templates are so short.

1 A visitor opens a URL /pt-pt/sobre 2 FolioPress finds the language and the page pt-PT · “Sobre” 3 It builds the context page, site_title, languages, navigation… 4 It picks a template page-sobre.html? else page.html 5 Rendered inside base.html HTML sent to the browser Every value arrives already translated into the visitor’s language, so templates just print what they are given.
One request, start to finish. Steps 3 and 4 are where a theme does its work.

Two things worth noticing. The language is read from the URL prefix, so /pt-pt/sobre and /about are the same page in two languages, and the context handed to your template is already in the right one — every title, slug, menu label and piece of content. And the template is chosen for you: for a page whose primary-language slug is about, FolioPress looks for page-about.html first and falls back to page.html. There is nothing to wire up.

One shell, many pages

Templates are Jinja2 — HTML with two extra kinds of bracket. {{ page.title }} prints a value. {% if page.poster_image_url %} … {% endif %} is an instruction: loops, conditions, and inheritance.

base.html the shell shared by every page <head>, navigation, language switcher {% block content %} the one gap each template fills footer, scripts Each of these fills the slot: home.html {% extends %} page.html {% extends %} post.html {% extends %} tag.html {% extends %} search.html {% extends %}
Header and footer live in one place; every other template only writes what goes in the middle.

base.html holds everything every page shares — the <head>, navigation, language switcher, footer, scripts — with one gap in the middle. Every other template starts the same way and fills that gap:

{% extends "base.html" %}

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

Anything repeated across templates — a post card, a pagination bar — goes in partials/ and is pulled in with {% include "partials/post-card.html" %}.

theme.yml: the settings panel

A theme describes itself and configures a few behaviours here, without touching a template. This is what the built-in themes use:

title: "Ink"
description: "A clean, minimal theme"
version: "1.0.0"
author: "FolioPress"

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

settings:                    # free-form; available to templates as theme_settings
  primary_color: "#2563eb"

home:
  posts: 10                  # how many posts the home page receives
  order_by: "-published_at"  # newest first
  has_pagination: false      # a cutoff, or real page links?
  image_ratios:
    poster: "16:9"           # what the admin's cropper offers for this page type
    gallery: "free"

page:  { posts: 10, order_by: "-published_at", has_pagination: false }
post:  { posts: 3,  order_by: "-published_at" }   # related posts under a post
tag:   { posts: 12, order_by: "-published_at", has_pagination: true }

page-about:                  # override for one page, by its primary-language slug
  image_ratios: { poster: "1:1" }
post-tutorials:              # override for posts whose first tag has this slug
  image_ratios: { poster: "4:3" }
Key What it controls
title, description, version, author Shown on the admin’s Themes page.
meta.supports_dark_mode, meta.responsive, meta.categories Descriptive; used for the theme gallery.
meta.social_image A file in the theme's static/, drawn at 1200x630, used as the link-preview image when a page has no poster image of its own. Without it such a page shares as the 180x180 touch icon.
settings Anything you like. Handed to every template as theme_settings, so a colour or a tagline can live here instead of in CSS.
home, page, post, tag The list of posts that template type receives: posts (how many), order_by, and whether has_pagination adds real page links or just cuts the list off.
order_by -published_at (newest first) or published_at; likewise created_at and updated_at, with or without the leading -; or random.
image_ratios What the admin’s image cropper offers when editing that content type: a ratio like 16:9, a list of them, or free. Separate values for poster and gallery.
page-<slug>, post-<tag-slug> The same keys, overriding page/post for one page (by its primary-language slug) or for posts whose first tag has that slug.

What every template can see

A template never fetches anything. It is handed a bundle of ready-to-use values — FolioPress calls it the context — and prints what it needs. Part of the bundle is the same on every page; the rest depends on which template is rendering.

Every template gets this bundle 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() …plus, depending on the template: 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
The shared bundle is the same on every page; the extras are what makes each template type different.

Shared by every template

Variable What it is
page The page or post being viewed, already resolved to the current language (see below). None on the tag archive and search.
site_title, site_description, footer From Site Settings, in the current language. footer is already rendered from Markdown.
navigation_menu The menu configured in the admin: a list of {title, url, slug, page_id}, each url ready to use in this language.
languages The site’s languages, keyed by code. Each entry: code, name, native_name, short_code, menu_label, flag.
current_language, current_language_code, default_language_code The language being rendered (same shape as a languages entry), its code, and the site’s primary language.
language_switch_urls Keyed by language code: the URL of this same page in that language, translated slug and all.
home_url, url_language_code The home page in the current language (/ or /pt-pt), and the lowercase prefix to put in front of any URL you build yourself ('' for the primary language).
static_url, media_url Where this theme’s files are served from (/static/<theme>), and where uploaded images live.
page_title, meta_description, canonical_url, hreflang_urls, social_image_url, twitter_card_type, json_ld Everything the <head> needs: a sensible tab title, absolute canonical and per-language alternate URLs, an Open Graph image, and structured data — all precomputed.
theme_name, theme_info, theme_settings The active theme’s name, its whole theme.yml, and its settings block.
t The theme’s own translator: {{ t('Read more') }} looks the phrase up in locales/<language>.json (see below).
request The FastAPI request, for the rare case you need the path or a query parameter.

The page object

The same shape whether it is a page, a post, or an entry in any list of posts:

Field Notes
title, slug, url In the current language. url is complete, including the language prefix or the language’s own domain.
content Already converted from Markdown to HTML — print it with {{ page.content | safe }}.
excerpt, meta_description Short forms, in the current language.
poster_image_url Full URL of the poster image, or None.
gallery, content_gallery Lists of images: {id, url, title, width, height, original_filename}. Use image.url directly.
tags {slug, title, url} for each public tag on it. Private tags are never included.
is_post, is_featured, published_at, created_at, updated_at Whether it is a post, whether it is featured, and its dates.
id The database id — handy for anchors and cache keys.

Lists of posts

Template Variable Contents
home.html posts (alias recent_posts), pages, is_home Posts sharing a tag with the home page, sized and ordered by theme.yml’s home section. A home page with no tags receives no posts — tag it to fill it.
page.html posts (alias page_posts) Posts sharing a tag with this page, from the page section. This is how a “Blog” page lists its posts.
post.html related_posts, is_post Other posts sharing a tag with this one, from the post section.
tag.html tag (title, slug), posts Every published post carrying this tag, from the tag section.
search.html search_query, search_results, results_count The term entered and the matching pages and posts.

Every list of posts comes with pagination in the same shape: has_pagination, current_page, total_pages, has_previous, has_next, previous_page, next_page, and pagination_urls with first, previous, next and last — None where there is nowhere to go. When `has_pagination` is off in `theme.yml`, the list is simply cut at `posts` and the flags stay false.

Languages and the switcher

Two of the language fields are computed for you. short_code is the language code alone — EN — unless the site also enables another variant of the same language, in which case it keeps its region: EN-US and EN-GB. menu_label follows the same rule with names: English, or English (US) and English (UK). This is the exact rule the admin’s own language switcher uses, so the site and the admin never disagree about a label. A complete switcher is therefore short:

{% 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 already points at the same page in each language — the translated slug, or the home page if that translation does not exist yet — so a switcher never builds a URL itself.

The theme’s own words

Content is translated by the author in the admin. A theme’s own strings — “Read more”, “Search”, “All rights reserved” — are translated by the theme, in templates/locales/<language>.json:

{
  "Read more": "Ler mais",
  "Search": "Pesquisar",
  "Change language": "Mudar idioma"
}

Wrap the phrase in {{ t('Read more') }} and it renders in the visitor’s language. A phrase missing from that language’s file falls back to the primary language’s file, and then to the phrase itself — so a half-translated theme still renders, just in English where a translation has not been written yet. The files travel with the theme when it is exported.

One page, its own layout

A landing page, a contact form, a portfolio grid — sometimes one page deserves a layout none of the others need. Add a template named page-<slug>.html, where <slug> is that page’s slug in the primary language, and FolioPress uses it for that page in every language. Delete the file and the page quietly goes back to page.html.

{# 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 %}

The page an admin picks as the contact form page gets a little extra: is_contact_page is true, contact_form_action is where the form should post, and contact_guard_token / contact_honeypot_field are two hidden fields that keep bots out. After a submission, contact_status tells you whether to show a thank-you or an error.

Start from a theme that works

The fastest way to build a theme is to open one of the built-in ones — every one has a live demo — and read it next to this page. Duplicate it in the admin, change the CSS, then the templates, and you have something of your own without ever starting from a blank file.

Two habits that save time. Print values the context gives you rather than rebuilding them — page.url, tag.url and language_switch_urls already know about language prefixes and custom domains. And keep the shell in base.html: if you find yourself pasting a header into a second template, it belongs in the base.