Small CMS with template customization.
  • Python 67%
  • TypeScript 14.6%
  • HTML 12.5%
  • SCSS 4%
  • JavaScript 1.4%
  • Other 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Till Wesselmann 6fde66d2d7 Pillow 13, Django 6.1.3
Pillow 12.3.0 carries 41 advisories that 13.0.0 fixes and no 12.x release will,
among them a JPEG decoder buffer overflow (CVE-2026-112398) and TIFF and WMF
out-of-bounds reads, all reachable from an uploaded image. Django 6.1.3 fixes the
base64 memory exhaustion in MultiPartParser (CVE-2026-108147) and the Vary cache
key mix-up (CVE-2026-113593); pyasn1 0.6.5 comes along for the BER decoding DoS
fixes.

The floors say what the app is meant to run on, so a re-lock cannot go back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 21:46:52 +01:00
.github/workflows Run the checks that were only ever run by hand 2026-09-04 20:52:24 +02:00
assets Let the dashboard live under the policy it is about to be served with 2026-09-19 21:59:11 +02:00
src Put the turn on the edit screen 2026-09-19 22:07:56 +02:00
.dockerignore Dockerfile: let the documented commands actually run 2026-09-02 16:35:36 +02:00
.editorconfig Modernize the toolchain: uv, django 6.1, vite, gunicorn 2026-09-02 16:21:54 +02:00
.gitignore Modernize the toolchain: uv, django 6.1, vite, gunicorn 2026-09-02 16:21:54 +02:00
.prettierignore Modernize the toolchain: uv, django 6.1, vite, gunicorn 2026-09-02 16:21:54 +02:00
.prettierrc Code format 2025-01-01 20:49:19 +01:00
.python-version Modernize the toolchain: uv, django 6.1, vite, gunicorn 2026-09-02 16:21:54 +02:00
Dockerfile Purge the dashboard stylesheet against the markup that uses it 2026-09-03 18:24:32 +02:00
eslint.config.js Let eslint see the types it has been linting without 2026-09-04 20:51:52 +02:00
LICENSE Added LICENSE file 2018-09-22 19:17:35 +00:00
package-lock.json Purge the dashboard stylesheet against the markup that uses it 2026-09-03 18:24:32 +02:00
package.json Purge the stylesheet against every template a field is drawn by 2026-09-04 20:52:10 +02:00
pyproject.toml Pillow 13, Django 6.1.3 2026-11-06 21:46:52 +01:00
README.md Let the dashboard live under the policy it is about to be served with 2026-09-19 21:59:11 +02:00
tsconfig.json Purge the dashboard stylesheet against the markup that uses it 2026-09-03 18:24:32 +02:00
uv.lock Pillow 13, Django 6.1.3 2026-11-06 21:46:52 +01:00
vite.config.js Let the dashboard live under the policy it is about to be served with 2026-09-19 21:59:11 +02:00
vitest.config.js Let the gallery be opened by a keyboard and by a thumb 2026-09-04 20:28:40 +02:00

Bokstaever

Host your own website with support for blogging, editing pages and external static content. It's a cms written in Python featuring the awesome webframework Django.

Current Features:

  • formatting of posts and pages via markdown
  • fully customizable templates
  • ability to load static pages with external dependencies
  • Image upload and resizing

Getting started

uv manages the python side: uv sync reads pyproject.toml and uv.lock, picks the interpreter named in .python-version and installs the dependencies plus the dev group into .venv.

uv sync
npm install

Copy the environment template and fill in a secret key. src/.env is not tracked.

cp src/.env.example src/.env

Then set up the database and build the assets:

createdb bokstaever  # postgres, or use sqlite via DATABASE_URL

uv run src/manage.py migrate
uv run src/manage.py compilemessages

# build the ts/scss bundles into src/dist/
npm run build

If you're in a hurry a sqlite database works too, set DATABASE_URL=sqlite:////tmp/bokstaever.sqlite in src/.env.

Run the development server with:

uv run src/manage.py runserver
# in a second shell, to rebuild the bundles on change
npm run dev

uv run uses .venv without activating it; activate it (source .venv/bin/activate) if you prefer to call manage.py, pytest and ruff directly.

npm run build has to run before collectstatic: the templates resolve their bundles through the manifest vite writes to src/dist/.vite/manifest.json.

Static assets

The bundles are built by vite from assets/ into src/dist/, with a content hash in every file name. src/dist is a STATICFILES_DIRS entry, so collectstatic copies it into src/static.

Templates never name a bundle file directly. The {% vite %} tag (src/bokstaever/templatetags/vite.py) takes an entry name from rollupOptions.input in vite.config.js and renders the stylesheet, module preloads and script tag for it:

{% load vite %}
{% vite "dashboard/app" "css" %}   {# in the head #}
{% vite "dashboard/app" "js" %}    {# at the end of the body #}

Passing no second argument renders both. Adding a bundle means adding an entry to vite.config.js and referencing that name; a name that is not in the manifest raises ImproperlyConfigured instead of silently rendering nothing.

Bundles of an extension app

An extension app (BOKSTAEVER_EXTENSION) cannot write into src/dist, which belongs to the bokstaever image. It builds into its own static directory instead, <app>/static/<app>/dist, and the tag searches that manifest before bokstaever's own. So {% vite "windsbraut/app" "css" %} resolves out of the extension, and an entry it names like one of bokstaever's replaces it, the way its templates already replace bokstaever's templates.

Django's app directory finder serves that directory, so it needs no STATICFILES_DIRS entry of its own and works under runserver unchanged. An extension that ships no bundles at all needs no manifest.

Caching

The page cache does nothing until CACHE_URL is set. The default is dummycache://, which discards every write, so an unconfigured deployment renders every page on every request. Point it at a redis - production already runs one for the rq queue, on a different database number:

CACHE_URL="rediscache://localhost:6379/1"
DJANGO_REDIS_QUEUE="redis://localhost:6379/0"

Every process needs it, not just the web one - see the rq worker under Deployment. manage.py check warns when it is missing and DEBUG is off.

Two deployments that share one redis database also share the keys, so the staging site's publish would orphan the live site's cache and hand it bodies rendered against the wrong MEDIA_URL. Give each either a database of its own, as the numbers above do, or a DJANGO_CACHE_KEY_PREFIX. Setting one changes every key at once, which invalidates that deployment's cache exactly once.

Two things are cached, under two version numbers (bokstaever/cache.py):

  • Rendered pages, for anonymous visitors only. A request from a signed in user is neither served from the cache nor stored in it: the frontend views show drafts to editors, and a cache keyed by url alone would hand an editor's copy of the front page to the next visitor.
  • Rendered markdown, keyed by a hash of the text. The index page runs the parser over every post body in full only to truncate the result, so this is what makes the first request after a publish cheap.

A write to a model the site renders - a post, a page, a category, a fragment - moves the page version forward, which orphans every stored page at once. Images and galleries move a second version that the rendered markdown uses as well, because they are what the ![pk] and !(pk) embeds read. Both bumps happen when the transaction commits.

Settings that change a page without changing the database - PAGE_SIZE, the image feed, BOKSTAEVER_SITE_URL - are part of the cache key. What no setting describes, a template a theme changed or the code itself, needs the deploy to say so. PAGE_VERSION in bokstaever/cache.py covers the deploy that forgets, and developing against a cache that is already warm:

docker run --rm -e CACHE_URL=... bokstaever ./manage.py invalidate_cache

Responses carry a short max-age, and ConditionalGetMiddleware answers the revalidation that follows with a 304 off the cached body. Anything rendered for a signed in user, and anything that hands out a cookie, is marked private, no-store; a view an extension app adds is private until it says otherwise, by using DatabaseAwareCacheMixin or @cache_control.

Tests

uv run pytest                      # python
uv run pytest --cov                # with coverage
npm test                           # javascript

The python suite does not read src/.env: pytest-env in pyproject.toml sets the variables the settings need, so a run does not depend on the local environment. It does need a postgres database unless DATABASE_URL is overridden.

npm test runs vitest over assets/**/test/ in jsdom: a hundred tests across the picker, the upload widget, the bulk selection, the markdown insertion and the public image feed, which is where the browser side logic lives. Each module exports what its bundle builds rather than only instantiating itself on load, so a test constructs one over a DOM it wrote itself instead of loading a page.

In a browser

npm run build                          # the bundles the pages load
uv run playwright install chromium     # once
uv run pytest -m browser

src/dashboard/tests/test_browser.py drives the dashboard in chromium against a live server: it signs in, opens every list, inserts a picture into a post, arranges a gallery, uploads by drop, paste and dialog, ticks and deletes a selection, and does it again in German and under the dark scheme. Anything the browser reports - a console error, an uncaught exception, a request that failed, a response of 400 or more - fails the test. It exists because the suite above once passed while the image picker was completely broken: a template comment written across lines was served as text, and only a browser running the page noticed.

The tests carry the browser marker and the ordinary run deselects them, so uv run pytest never needs a browser. Run them after a change to a template or a bundle; they take about ten seconds. Without playwright, without its chromium, or without src/dist/.vite/manifest.json they skip and say why.

Code quality

npm run lint       # eslint, prettier, djlint, ruff
npm run fix        # the same tools, applying what they can fix
npm run typecheck  # tsc and mypy

npm is the entry point for both languages; the python tools are invoked through uv run. Notes:

  • ruff does the import sorting (rule I), there is no separate isort.
  • the ruff formatter wraps at 88, E501 only fires past 120.
  • eslint runs type aware on assets/**: recommendedTypeChecked, which is what makes no-floating-promises possible - three fire and forget fetches had sat in the picker with nothing to report them. no-explicit-any and the no-unsafe-* rules that follow from it are warnings rather than errors: the image feed and the upload widget pass the api's json around untyped, and giving those responses real types is application work.
  • the browserslist in package.json and build.target in vite.config.js are the same five browsers, spelled twice because vite reads no browserslist and autoprefixer reads nothing else. They are vite 8's own baseline-widely-available default, pinned so that an npm update cannot move the floor under autoprefixer. Move them together or not at all.
  • pyproject.toml records two deliberate exemptions, both marked for the application pass: DJ001/DJ008/DJ012 on the models, and django-stubs' misc errors on dashboard.views.

Deployment

The Dockerfile builds the bundles, resolves the locked python dependencies into /opt/venv, compiles the translations and copies all three into a slim runtime image that runs gunicorn as an unprivileged user.

docker build -t bokstaever .

The default command runs collectstatic and manage.py check --deploy before gunicorn. check --deploy aborts the start on an error and logs its warnings, so the deployment settings are reported on every start.

Migrations are not run by the container. Run them yourself when deploying a new version. The startup sequence is a CMD, so passing a command replaces it:

docker run --rm -e DATABASE_URL=... bokstaever ./manage.py migrate

Two directories are meant to be mounted, and are the only ones the application writes to:

  • /app/static — filled by collectstatic on every start
  • /app/bokstaever/media — the uploads

Everything the application logs goes to stdout: warnings and above from django, including the traceback of a 500, and the progress of the image processing from images. There is no log file and no log rotation inside the image - the platform in front of it collects the stream.

The application does not serve /static/ or /media/ itself. With DEBUG=False django serves neither, so the reverse proxy in front of it has to map both directories. A container queried directly will return the pages but 404 every asset.

It is also where the cache headers for those two directories are set. Vite writes a content hash into every bundle name, so those may be cached forever; everything else collectstatic copies - django's admin assets, a theme's static images - keeps its name across a deploy and has to be revalidated. A Caddy configuration that does both:

example.org {
    # The application does not compress, and does not need to.
    encode zstd gzip

    # The upload form refuses anything over `BOKSTAEVER_MAX_UPLOAD_MB` as well,
    # but a request refused here never reaches a worker and never lands on the
    # disk. Keep the two numbers together.
    request_body {
        max_size 40MB
    }

    handle_path /static/* {
        root * /path/to/static
        file_server

        # Vite names a bundle `<name>-<hash>.<ext>` with an eight character
        # hash that may itself contain `-` and `_`, e.g. `default/app-upv-XOu-.js`.
        @hashed path_regexp -[A-Za-z0-9_-]{8}\.[a-z0-9]+$
        header @hashed Cache-Control "public, max-age=31536000, immutable"

        @plain not path_regexp -[A-Za-z0-9_-]{8}\.[a-z0-9]+$
        header @plain Cache-Control "public, max-age=3600, must-revalidate"
    }

    # A resized variant keeps its name when an image is processed again, so
    # revalidate rather than cache forever.
    handle_path /media/* {
        root * /path/to/media
        file_server
        header Cache-Control "public, max-age=86400, must-revalidate"
    }

    reverse_proxy http://bokstaever:8000
}

The proxy must not cache the proxied responses themselves; the application already does that, and it knows when to stop.

Languages

English and German. LocaleMiddleware settles each request on one of them, in this order: the django_language cookie the switcher in the dashboard navbar writes, then the browser's Accept-Language, then DJANGO_LANGUAGE_CODE. So a German reader gets a German site without configuring anything, and an editor whose browser asks for English can still say otherwise - the switcher is on the login page too, which is the only place it would be reachable from for someone who cannot read the dashboard yet.

The catalogues live in <app>/locale/de/LC_MESSAGES/django.po. The compiled .mo files are not tracked, so compilemessages has to run before the German is visible - the container image builds them in a stage of its own. Note that compilemessages skips a .mo that is newer than its .po, so an edited catalogue that appears not to take effect usually wants its .mo deleted first; test_i18n.py fails on a .mo older than its source for that reason.

A string added to the code is english until somebody runs makemessages in the app it belongs to and writes the German:

cd src/dashboard && uv run django-admin makemessages -l de

Do it in the same change rather than later. test_i18n.py holds every catalogue to having no empty and no fuzzy msgstr and to carrying the placeholders of its msgid, so a new string that nobody translated fails the suite instead of quietly reaching a German editor in english - which is how the writing screens, every save message and the login stayed untranslated for years.

Django merges app catalogues so that the app listed first in INSTALLED_APPS wins, and django.contrib.admin is listed first - it ships German of its own for a couple of dozen of the same strings, Save, Log out, Yes, Home and View on site among them. Save renders as its Sichern rather than this project's Speichern for that reason. Where the admin's wording is fine, the entry here is worded the same with a comment saying why, so that the catalogue does not promise a translation the page never uses. Where the wording matters, give the string a context ({% translate "Home" context "dashboard navigation" %} and a msgctxt in the catalogue), which no other catalogue defines and this one therefore wins.

Because the language is not in the url, it is part of the page cache key and public responses carry Vary: Accept-Language alongside the Vary: Cookie they already had. A reverse proxy that caches on bokstaever's behalf has to honour both, which is another reason the configuration below does not cache the proxied responses.

collectstatic copies; it never removes. /app/static is a mounted volume that survives the container, so every bundle any deploy has ever built stays in it - and vite names bundles by content hash, so a changed stylesheet arrives under a new name and leaves the old one behind rather than replacing it. That is deliberate on the serving side: a reader holding a page from the previous version can still fetch the assets it asks for, which is why the hashed names may be cached forever. It does mean the directory only grows, and a dependency that is dropped leaves its files there for good - removing bootstrap-icons took two webfonts out of the build, and any volume that had collected them keeps them until someone says otherwise.

Emptying it is a deploy step rather than a start-up one, which is why the CMD does not pass --clear: that flag deletes the whole directory before it copies, and this one is being read by the proxy while the container starts. Do it when no container is serving from the volume:

docker compose stop web
docker compose run --rm web ./manage.py collectstatic --noinput --clear
docker compose start web

Set DJANGO_BEHIND_PROXY=true when a reverse proxy terminates TLS. It makes django trust X-Forwarded-Proto and, in gunicorn.conf.py, decides whether the forwarded headers are accepted at all; the two have to agree, which is why they read the same variable.

With DEBUG=False the HTTPS hardening turns itself on: secure session and CSRF cookies, a redirect to https, and a one hour HSTS header. SECURE_SSL_REDIRECT needs DJANGO_BEHIND_PROXY=true behind a TLS terminating proxy, otherwise django only ever sees http and redirects to itself forever. Each setting has its own DJANGO_SECURE_* variable for the cases that do not fit, e.g. turning the redirect off because the proxy already does it. SECURE_HSTS_INCLUDE_SUBDOMAINS and SECURE_HSTS_PRELOAD stay off until you opt in: preloading is hard to undo, browsers ship the list for months.

Image resizing is handed to an rq worker, which is a separate process:

docker run --rm \
  -e DATABASE_URL=... \
  -e DJANGO_REDIS_QUEUE=redis://... \
  -e CACHE_URL=rediscache://... \
  bokstaever ./manage.py rqworker

Give the worker the same CACHE_URL the web process has. It is not optional. When a picture is finished the worker writes its dimensions and moves the media version, which is what tells the web process to stop serving the unprocessed markup; without the setting that bump lands in the worker's own dummycache:// and the site shows the placeholder branch for the page timeout, and bodies rendered without image dimensions for up to a day. The same is true of manage.py invalidate_cache and manage.py restore_image_dates, which otherwise report success against a cache nobody reads. Every one of them says so at startup now - bokstaever.W002, which the system checks raise whenever the default cache is the dummy backend and DEBUG is off.

Mail

Nothing in bokstaever sends any yet. There is no password reset route and no contact form; the settings are here so that an extension app can send_mail without the deployment being changed underneath it, and so that a deployment can create the account once.

The default is the console backend, which writes the message to the log. Setting DJANGO_EMAIL_HOST - and nothing else - switches it to SMTP. It is that variable and not DEBUG that decides, so a developer with DEBUG=False and no mail server gets the console rather than a connection refused, and a deployment that forgot the variable prints to its log rather than mailing out of localhost.

DJANGO_EMAIL_HOST=mail.example.org
DJANGO_EMAIL_HOST_USER=bokstaever
DJANGO_EMAIL_HOST_PASSWORD=...
DJANGO_DEFAULT_FROM_EMAIL=bokstaever@example.org

SERVER_EMAIL is set and reaches nothing: LOGGING has no mail_admins handler, and the tracebacks go to stdout.

check --deploy, which the image runs before gunicorn binds, calls the console backend an error. mail.E001 is silenced in the settings for that reason: a deployment with nothing to send is not refused a start for having no server to send it through.

Overriding settings

Local overrides, e.g. LDAP authentication, are read from an optional overrides.py next to manage.py, which the deployment mounts into the image. Only a missing file is tolerated: an override that fails to import raises rather than silently leaving the deployment on the defaults.

Who may edit

Signing in is not enough. An editor is a member of staff — is_staff on the django user. Every screen of the dashboard and the api's upload route ask for it; somebody signed in without it gets a 403, and somebody not signed in gets the login page. The rest of the api — the image feed's listing — stays public, because the theme fetches it anonymously.

Grant it in the admin, or with manage.py shell:

./manage.py shell -c "
from django.contrib.auth import get_user_model
get_user_model().objects.filter(username='edda').update(is_staff=True)
"

This matters most where authentication is not local. django-auth-ldap creates a django user for anybody the directory lets bind, so without the flag "can log in to the directory" would mean "can delete every post". A directory can grant it from a group rather than one account at a time:

AUTH_LDAP_USER_FLAGS_BY_GROUP = {
    "is_staff": "cn=editors,ou=groups,dc=example,dc=org",
}

AUTH_LDAP_REQUIRE_GROUP is worth setting as well — it keeps a user row from being made at all for somebody who is not in the group — but the dashboard no longer depends on the deployment remembering it.

Configuration

Everything is read from the environment (or src/.env) by django-environ.

Variable Default Meaning
DJANGO_SECRET_KEY required Django's secret key
DATABASE_URL required e.g. postgres:///bokstaever
DJANGO_DEBUG False Never enable in production
DJANGO_ALLOWED_HOSTS empty Comma separated
DJANGO_CSRF_TRUSTED_ORIGINS empty scheme://host, needed behind a TLS proxy
DJANGO_BEHIND_PROXY False Trust X-Forwarded-Proto
DJANGO_SESSION_COOKIE_SECURE not DEBUG Session cookie over https only
DJANGO_CSRF_COOKIE_SECURE not DEBUG CSRF cookie over https only
DJANGO_SECURE_SSL_REDIRECT not DEBUG Needs DJANGO_BEHIND_PROXY behind a proxy
DJANGO_SECURE_HSTS_SECONDS 0 / 3600 0 with DEBUG, else one hour
DJANGO_SECURE_HSTS_INCLUDE_SUBDOMAINS False Opt in, all subdomains must be https
DJANGO_SECURE_HSTS_PRELOAD False Opt in, hard to undo
DJANGO_LANGUAGE_CODE en-us Fallback only, see Languages below
DJANGO_EMAIL_HOST none Set it to send by SMTP, see Mail; unset is the console
DJANGO_EMAIL_PORT 587
DJANGO_EMAIL_USE_TLS True STARTTLS on the port above
DJANGO_EMAIL_HOST_USER empty
DJANGO_EMAIL_HOST_PASSWORD empty
DJANGO_EMAIL_TIMEOUT 10 Seconds to wait for the mail server
DJANGO_DEFAULT_FROM_EMAIL bokstaever@localhost Sender of everything the application sends
DJANGO_SERVER_EMAIL the sender above Sender of error mail; nothing mails errors today
TIME_ZONE UTC
CACHE_URL dummycache:// e.g. rediscache://host:6379/1, see below
DJANGO_CACHE_KEY_PREFIX empty Set it when two deployments share a redis database
DJANGO_CONN_MAX_AGE 60 Seconds to reuse a database connection
DJANGO_REDIS_QUEUE redis://localhost:6379/0 Queue for image processing
BOKSTAEVER_SITE_NAME bokstaever Used e.g. for the rss feed
BOKSTAEVER_SITE_DESCRIPTION bokstaever is a small cms.
BOKSTAEVER_SITE_URL empty Canonical origin, https://example.org
BOKSTAEVER_IMAGE_FEED False Enables the /images page
BOKSTAEVER_IMAGE_FEED_PATH images Path of the image feed
BOKSTAEVER_IMAGE_PAGE_SIZE 16 Images on the feed
BOKSTAEVER_PAGE_SIZE 12 Posts per page
BOKSTAEVER_MAX_UPLOAD_MB 40 Largest picture the upload form accepts
BOKSTAEVER_EXTENSION none Extra app prepended to the urlconf, may declare fragments
GUNICORN_WORKERS / GUNICORN_THREADS 2 / 4 Size postgres for workers x threads
ENVIRONMENT_FILE .env The file below src/ the rest of this table is read from

Three routes serve the machines rather than the reader, and none of them is a file the reverse proxy could hold:

  • /sitemap.xml lists every published post and page, plus the index and the image feed. Drafts stay out; a page kept out of the menu with show_menu stays in, because that flag decides what the navigation lists and not what is public.
  • /robots.txt keeps crawlers out of /dashboard/, /admin/ and /api/, and points at the sitemap.
  • /feed/ and /feed/atom/ carry the latest posts with their bodies rendered.

BOKSTAEVER_SITE_URL is the origin all of that is written in terms of, e.g. https://example.org - scheme and host, nothing else. Set it. Left empty, every absolute url is built from whichever Host header a request arrived with, which is wrong in two ways: a crawler cannot match those urls against the ones it already has, and the page cache has to be split per host to avoid handing one visitor's canonical url to the next (see bokstaever.cache.cache_origin). manage.py check --deploy warns when it is unset.

A post is served at /posts/<pk>/<slug>. The primary key is what identifies it, so a stale slug and the older /posts/<pk> both redirect permanently to the canonical url rather than 404ing or serving a second copy of the same post.

Templating

The templates under src/bokstaever/templates/default are the default theme and are a good starting point for your own. A theme supplies base.html, index.html, page.html, post.html and, if the image feed is enabled, images.html.

What a theme has to carry over

A theme's templates replace bokstaever's outright, so three things in the head are a theme's responsibility:

{% load bokstaever %}
<html lang="{{ LANGUAGE_CODE }}">
  <head>
    {% seo %}
  </head>
</html>

LANGUAGE_CODE is the language this request is being answered in, not the configured one - see Languages below - so a theme that hardcodes the attribute tells a screen reader the wrong thing as soon as a German reader arrives.

{% seo %} renders the title, the meta description, the canonical url, the Open Graph and Twitter tags, the link to the feed and the JSON-LD. It works the rest out of the context the view already built - the object a DetailView is showing, the paginator, the category the index was filtered by - so a view a theme adds itself gets a correct head without doing anything. To say more than it can work out, either pass it in:

{% seo title="Something else" description="..." %}

or have the view push a seo dict into its context, which is what ImageView does. Overriding snippets/meta.html replaces the markup wholesale; it is rendered by an inclusion tag, so it is handed that dict and nothing else - none of the context processors' variables reach it.

Two things changed shape for themes written against an earlier version: {% block title %} is gone, because the title and the og:title next to it have one source now, and {% url "frontend:post-detail" post.pk %} needs a slug as well - use {{ post.get_absolute_url }}.

A theme that brings stylesheets or scripts of its own builds them with vite into <app>/static/<app>/dist, see Static assets.

What the page is allowed to run

Every response carries a Content-Security-Policy, SECURE_CSP in the base settings. A script runs only if its tag carries the nonce of that response, and whatever such a script imports is trusted with it; no origin is named, not even the site's own, which is what keeps a file somebody uploaded to /media/ from ever running as one. {% vite %} writes the nonce on the tags it emits, so a theme that loads its bundles through it has nothing to do.

What a theme writes out by hand is its own to mark:

<script nonce="{{ csp_nonce }}">document.documentElement.dataset.js = "";</script>

There is no such way out for an onclick="..." or a style="..." attribute. Give the element a data- attribute or a class and let the bundle or the stylesheet do it - a script setting element.style is fine, it is only the attribute in the markup that is refused. Pictures, fonts, video and stylesheets come from the site itself; one from another origin needs that origin added to the policy, in the settings override of the deployment.

Nothing about a refusal shows on the server: the element is simply not there or does nothing, and the browser's console says why. bokstaever/tests/test_templates.py finds the two attributes in this repository's templates, and pytest -m browser fails on any console error.

Fragments

Some of what a theme shows is text the editors should own without it being a page: the greeting on the front page, the menu, a notice in the footer. A fragment is that - text the theme asks for by key, with no url, no sitemap entry, no draft flag and no place in the menu. The theme declares which keys it expects as slots on its AppConfig, with the label the dashboard shows and a hint on what to write:

from bokstaever.fragments import Slot
from django.apps import AppConfig


class WindsbrautConfig(AppConfig):
    name = "windsbraut"
    fragments = [
        Slot(
            "greeting",
            "Begrüßung auf der Startseite",
            help="Die erste Zeile muss eine Markdown-Überschrift sein.",
        ),
        Slot("menu", "Hauptmenü", help="Eine Markdown-Liste von Links."),
    ]

The dashboard offers exactly these under "Fragments", in this order, so nobody has to spell a key. A key has to be slug-shaped and declared once, and a slot's kind has to be one the dashboard has an editor for; the system check bokstaever.E010 refuses anything else at startup. A slot's type is what a fresh fragment starts as, md unless said otherwise. Rows whose key the theme no longer declares are neither deleted nor hidden: the dashboard lists them as not used by the theme, with a delete action, so a theme rolled back finds its text still there.

A template reads a fragment with a block tag and carries the fallback in its body, which is rendered while the fragment is missing or blank:

{% load bokstaever %}
{% fragment "greeting" %}
  <h1>Segeln, Natur erleben, nachhaltig handeln</h1>
  <p>…</p>
{% endfragment %}

The written text goes through the same renderer a page's body does, markdown cache and ![pk] embeds included. A theme that parses the text instead of rendering it - a menu read as a list of links - calls bokstaever.fragments.text("menu"), which returns the text or None and leaves the fallback to the caller.

A slot declared kind="gallery" is filled by picking one of the galleries rather than by typing. It is for a stock of photographs the theme lays out itself - the strip across a banner, the picture beside a column - which as a text slot means a list of ![pk] embeds, a column of numbers copied out of the image list, in a box that shows nothing of what it points at.

Slot(
    "banner",
    "Bannerfotos",
    kind="gallery",
    help="Die Fotos, die oben über jeder Seite wechseln.",
)

The dashboard offers the slot as a select of the galleries by name, with a link to the gallery screens, and no text field, type or insert buttons; the list says which gallery the slot holds and how many pictures are in it. Which photographs those are, and in which order, is arranged on the gallery's own screen - so the editors have the picker, the ordering and the cache invalidation that were already there.

A theme reads the slot with bokstaever.fragments.gallery("banner"), which returns the Gallery or None - while nobody has picked one, and once the gallery a slot pointed at has been deleted, since the pick is emptied rather than the fragment removed. {% fragment "banner" %} renders its fallback body on a gallery slot and fragments.text("banner") answers None, whatever is picked and whatever the row holds: the declaration decides what a slot is, so the two text readers answer as if nothing had been written there.

from bokstaever import fragments


class Index(ListView):
    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context["banner"] = fragments.gallery("banner")
        return context
{% for image in banner.ordered_images %}
  {% include "snippets/image.html" with image=image %}
{% endfor %}

ordered_images() and not images.all(), which knows nothing of the order the gallery was arranged in; a view that draws several galleries prefetches instead, see rendering.render.

The theme is what says which kind a slot is, so a slot turned from a text into a gallery opens as the picker from then on. What was stored under the other kind stays on the row, out of sight, and comes back whole if the theme changes its mind again - deleting the fragment is the only thing that throws it away. A row whose key the theme no longer declares is a text screen, since without a slot there is nothing left to say it was anything else.

Out of sight to the theme as well: from the moment the slot is declared a gallery, {% fragment "banner" %} renders its fallback and fragments.text("banner") is None, so a template left standing on the slot - the one thing nobody remembers to change - shows the theme's own markup rather than the list of ![pk] embeds that used to be typed there. The text is not thrown away, only unreadable from the theme: the dashboard still lists the row and its delete action still throws it away for good.

The summary

post.summary is what an author wrote about the post instead of its first lines: a theme renders it as the teaser and {% seo %} puts it in the meta description, the og:description and the JSON-LD. It is plain text with no type of its own - it ends up inside an attribute - and it has no max_length, so a theme that wants a longer teaser may have one; the head shortens what it takes to 155 characters on a word boundary.

{% if post.summary %}
  {{ post.summary }}
{% else %}
  {{ post.text|markdownify|truncatechars_html:160|striptags }}
{% endif %}

The fallback is the point: a post written before the field existed, or imported without an excerpt, has none, and truncating the body cuts the first paragraph off mid-sentence. The feed carries the whole rendered body either way - a subscriber asked for the posts, not for the teasers.

Older instances

Content pages are database backed and edited in the dashboard; the template bundle mechanism earlier versions used was removed. An instance still on it is migrated with manage.py import_legacy, which reads a dumpdata auth.user images bokstaever export.

An instance imported before images migration 0011 has the instant of that import stored as every image's creation_date: the field was auto_now_add, which overwrites whatever the importer assigned. manage.py restore_image_dates <export> reads the same export again and moves nothing but the dates, --dry-run first to see what it would change. It is the only record of them there is - the files the old site left behind carry no exif. Give it the live CACHE_URL, or the page cache it invalidates is a dummycache:// nothing reads. Run manage.py backfill_image_dimensions in the same pass if the instance predates 0010: without the pixel sizes every srcset falls back to the nominal IMAGE_SIZES and overstates the files.

After a deploy that adds a resized size

manage.py backfill_image_tiles writes the squares that were added to the thumbnail profile after a picture was processed, and moves each row's variant_version up as it goes. Until it has run, those rows are served the larger square they do have - Image.tiles decides that per row, so nothing breaks and nothing 404s while the backfill is pending. It re-encodes only the thumbnail profile, not the large sizes, so it is a few hundred milliseconds a picture rather than a full reprocess.

The one deploy that needs it is the one that introduced TILE_SIZE: every picture processed before it has no _tile file, and the dashboard's library goes on sending 600 pixel squares into 130 pixel tiles until the command has run.