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.
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.
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 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.
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.