- Python 67%
- TypeScript 14.6%
- HTML 12.5%
- SCSS 4%
- JavaScript 1.4%
- Other 0.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
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> |
||
| .github/workflows | ||
| assets | ||
| src | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| .python-version | ||
| Dockerfile | ||
| eslint.config.js | ||
| LICENSE | ||
| package-lock.json | ||
| package.json | ||
| pyproject.toml | ||
| README.md | ||
| tsconfig.json | ||
| uv.lock | ||
| vite.config.js | ||
| vitest.config.js | ||
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,
E501only fires past 120. eslintruns type aware onassets/**:recommendedTypeChecked, which is what makesno-floating-promisespossible - three fire and forget fetches had sat in the picker with nothing to report them.no-explicit-anyand theno-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
browserslistinpackage.jsonandbuild.targetinvite.config.jsare the same five browsers, spelled twice because vite reads no browserslist and autoprefixer reads nothing else. They are vite 8's ownbaseline-widely-availabledefault, pinned so that annpm updatecannot move the floor under autoprefixer. Move them together or not at all. pyproject.tomlrecords two deliberate exemptions, both marked for the application pass:DJ001/DJ008/DJ012on the models, and django-stubs'miscerrors ondashboard.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 bycollectstaticon 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.
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 |
What crawlers and link previews see
Three routes serve the machines rather than the reader, and none of them is a file the reverse proxy could hold:
/sitemap.xmllists every published post and page, plus the index and the image feed. Drafts stay out; a page kept out of the menu withshow_menustays in, because that flag decides what the navigation lists and not what is public./robots.txtkeeps 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.
Gallery slots
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.