- Python 32.9%
- SCSS 27.4%
- JavaScript 25.9%
- HTML 13%
- Dockerfile 0.8%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
Two more Textbausteine, `newsletter` and `social`, both markdown lists of
links like the menu and read by the same `content.links`. The newsletter
stands at the top of "Auf dem Laufenden bleiben", because the signup lives on
somebody else's site and the layout cannot know where. The profiles are a line
of their own under the three columns, as words rather than logos, each with
`rel="me"` so that a Mastodon profile linking back verifies.
The Impressum moves down into that line. It sat under "Auf dem Laufenden
bleiben" only because there was nowhere else, and it never was a way of
staying up to date.
`{% fragment_links %}` accepts the keys `windsbraut.apps` declares and no
others: a misspelt key is a row that silently is not there, so it stops the
page under DEBUG the way a missing icon does. `tests/test_fragments.py` holds
the keys the layout reads to the slots the app declares, which is where the
one test about the menu's key went.
`.footer li + li` becomes `.footer__grid li + li` - it said the footer and
meant the columns, and the row at the bottom is the first list to tell them
apart.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
||
| assets | ||
| tests | ||
| windsbraut | ||
| .dockerignore | ||
| .editorconfig | ||
| .gitattributes | ||
| .gitignore | ||
| .prettierignore | ||
| .prettierrc | ||
| .python-version | ||
| .stylelintrc.json | ||
| crest.py | ||
| Dockerfile | ||
| eslint.config.js | ||
| package-lock.json | ||
| package.json | ||
| pyproject.toml | ||
| README.md | ||
| resize.py | ||
| svgo.config.js | ||
| uv.lock | ||
| vite.config.js | ||
| vitest.config.js | ||
windsbraut-ev.de
Layout and static pages of windsbraut-ev.de as an extension app for bokstaever.
bokstaever loads the app named in BOKSTAEVER_EXTENSION before its own apps:
the templates in windsbraut/templates/default/ replace the default layout,
windsbraut/urls.py adds the two urls it serves itself, Impressum and
Schiffsdaten. Everything else - posts, pages, the image feed and the
dashboard - is bokstaever.
Layout
assets/js/ the bundle entries and the behaviours they load: nav.js
(the menu), ship.js (the drawing), lightbox.js and
gallery.js (the enlarged view, see below)
assets/scss/ stylesheets; _tokens.scss holds the palette and the scales,
_kit.scss the breakpoints and the mixins, _layers.scss the
order the three bundles settle in
assets/fonts/ the text face and the display face, both self-hosted
assets/slides/ large originals of the slide images (git-lfs), resized
by resize.py while the container image is built
assets/logo/ the crest as Illustrator exported it, made transparent
by crest.py
tests/ the calendar reader, the menu, the views, the sitemap and
the two build scripts, run with plain pytest
windsbraut/ the Django app: templates, urls, views and static files
Design
The palette comes from the ship. The greens and the crimson are the values used
inside windsbraut.svg; the page ground is the grey-green of the Watt at low
tide rather than the cream a heritage site usually reaches for, and the muted
ink every caption and date is set in is a green-grey of the same family, because
a warm neutral on that ground reads as dirt. They are defined once, in
assets/scss/_tokens.scss, together with the type scale and the spacing scale.
No other stylesheet writes a literal colour - that is the one rule here with no
exceptions, and it is worth keeping that way. Sizes are looser: the scales are
tokens, and a constant that appears in one rule and nowhere else stays where it
is used, because a token used once is a name with no second reader.
The four breakpoints live in _kit.scss rather than with the tokens, because a
@media prelude cannot read a custom property: they have to be Sass variables,
and _kit.scss is the file that emits nothing and can therefore be used by
every bundle without reprinting the palette into it.
Text is set in Literata, ragged right and hyphenated, at about 65 characters to
the line; the chancery face is the wordmark only. Tex Gyre Chorus blocks
briefly and then stays, because swapping into it would change the shape of the
wordmark after the fact; Literata swaps.
The wordmark ships as texgyrechorus.woff2, a subset of the OTF beside it -
ASCII, the umlauts and the eszett, which is a good deal more than the two
strings it sets but enough that changing them cannot silently lose a glyph. The
OTF is the source and is not served: 101 kB of a format that carries no
compression and that Caddy does not gzip either, against 15 kB. npm run font
regenerates it.
The posts are log entries - several of them are titled "Logbuch" - so the front
page sets them as one, with the date in its own column. Schiffsdaten is the
one page that borrows from a sea chart: the technical data is tabulated with
lining figures and the drawings break out of the prose measure.
Static assets
The bundles are built by vite from assets/ into
windsbraut/static/windsbraut/dist, with a content hash in every file name.
The site cannot write into bokstaever's own dist/, which belongs to the base
image, so it builds below its own static directory instead; django's app
directory finder serves it and bokstaever's {% vite %} tag looks for an
extension's bundles at exactly that path.
Templates never name a bundle file directly:
{% load vite %}
{% vite "windsbraut/app" "css" %} {# in the head #}
{% vite "windsbraut/schiffsdaten" "js" %} {# at the end of the body #}
The entry names are the keys of ENTRIES in vite.config.js; adding a bundle
means adding one there and referencing that name. A name that is in neither
manifest raises ImproperlyConfigured instead of silently rendering nothing,
and {% vite "default/imageFeed" "js" %} in default/images.html reaches
bokstaever's own bundle the same way.
Everything under windsbraut/static/windsbraut/assets/ is unbundled and stays
on {% static %}: the slides, the film, the crest and the two drawings. The
fonts live in assets/fonts instead, so that vite hashes them along with the
css that declares them.
resize.py writes each slide at every width its original can fill and in three
formats - AVIF, WebP and JPEG, in that order - and {% slide %} renders the
whole <picture> from what it recorded, so a browser takes the first type it
knows and one that knows none of them still gets the JPEG. Every file carries a
hash of its own bytes in its name, which is what puts them in the deployment's
year-long immutable bucket rather than its hourly one; that is also why
windsbraut/slides.py reads slides.json instead of working the names out.
A slide the manifest does not know renders an empty slot, because a name that
was not read from the manifest is a name that cannot exist.
The drawings are Illustrator exports and are optimised by
svgo against svgo.config.js, with cleanupIds off: the
ship is inlined into the page so that ship.js can reach eleven groups inside
it by name, and renaming those ids would take the drawing apart. npm run svg
re-runs it over the two hand-drawn files; the crest goes through the same pass
inside crest.py, so regenerating it cannot put the export precision back.
The crest hangs from the top right corner of the masthead, twice the bar's
height, on a disc of the page ground - its two rings of lettering need that
size to be read at all, and the disc is what lets it overhang the page without
tangling with what is underneath. Below $bp-desk there is no corner to spare
and it is not drawn.
The crest is generated rather than drawn. Illustrator drew it in black on
white, so crest.py makes every black the page's ink and every white the page
ground, and writes one file per colour scheme because a <picture> cannot
reach the stylesheet's custom properties. The white shapes stay filled rather
than becoming transparent: the sails and the disc behind the ship are the
drawing's own depth, and letting the ground through them put the mizzen mast
and the shrouds back through the sailcloth. Filled costs nothing, since the
ground they are filled with is the disc the crest sits on:
The favicon falls out of the same script as a third output. The whole crest is
grey noise at sixteen pixels, so the icon is the drawing clipped to
Illustrator's own inner circle, where the ship has the frame to itself. It is
one file for both schemes, and follows neither: the disc is inset on a rounded
tile of $navy, drawn in the night's ink, and a navy tile reads on a light tab
bar and a dark one alike. The calendar's tab icon is the same tile around the
other mark, so the two tabs are a pair.
uv run python crest.py assets/logo windsbraut/static/windsbraut/assets
Content the layout reads
Four blocks of the layout are Textbausteine: text the layout asks
bokstaever for by key, with no url of their own, no draft flag and no place in
the menu. windsbraut/apps.py declares the ones this layout expects, with the
label and the hint the editors read, and the dashboard offers exactly those
under Textbausteine - there is no slug to spell right and nothing to remember
to tick. The greeting and the menu used to be pages marked as drafts, which was
a trick and leaked like one: a signed-in visitor could open /navigation and
read the menu as a page.
Three of the four are markdown lists of links and are parsed rather than
rendered, by windsbraut.content.links: the layout decides what each list is
drawn as - a row of nav links, a column in the footer, a line across the bottom
of the page - and markdown that arrived as a ready-made <ul> would decide it
instead. {% fragment_links "social" %} is how a template asks for one, and a
key windsbraut/apps.py does not declare stops the page under DEBUG the way
a missing icon does, because a misspelt key is a row that silently is not
there.
-
Begrüßung auf der Startseite (
greeting) is the text beside the slides on the front page. Its first line has to be a markdown heading: the front page takes itsh1from it, so a greeting that opens with a paragraph leaves the front page with noh1at all. Unwritten, the page shows the text the site shipped with, which is the body of the{% fragment %}tag indefault/index.html. -
Hauptmenü (
menu) is the menu, as a markdown list of links in the order they should appear:- [Mitsegeln](/mitsegeln) - [Bildungsarbeit](/bildungsarbeit)Unwritten, the menu falls back to bokstaever's own
SITEMAP, which is every page with "show in menu" set, ordered by headline. Either way the menu ends with Das Schiff, Bilder and the Törnplan, which the layout adds itself. -
Newsletter (
newsletter) is a list of the same shape, and it stands at the top of "Auf dem Laufenden bleiben" in the footer, above the RSS feed and the calendar the site serves itself. It is a Textbaustein because the signup lives on somebody else's site and this one cannot know where; unwritten, the column is the two entries it always was. -
Soziale Netzwerke (
social) is the list that closes the page: where the Verein is to be found off this site, as words rather than logos. The site draws three glyphs of its own and they are all line work out of the same hand, so three foreign trademarks would be the only shapes on it that do not come from this ship.Every link in it carries
rel="me". That is what verifies the site against a Mastodon profile linking back to it - fill the profile's website field in on norden.social and the check turns green - and it is true of the other profiles in exactly the same sense, so it goes on all of them rather than on the one with a use for it. Unwritten, the bottom line is the Impressum alone, which is where that link now lives: it sat in "Auf dem Laufenden bleiben" until there was a line for it, and it never was a way of staying up to date.The Signal newsletter is deliberately not linked from here. An invite with no sentence beside it saying who writes and how often is a link nobody follows, so it belongs on a page the Verein writes - which pages and the menu can already do, with no code at all.
The Impressum is a page, an ordinary published one under the slug impressum.
The address, the mail address and the name of the current Vorsitzender are what
German law asks the site to publish and the one part of it that changes on
nobody else's schedule, which is why it is a page and not a template any more.
The url stays this app's own: windsbraut/urls.py is included ahead of
bokstaever's page route, so /impressum is served by ImpressumView - and
while there is no page to show it renders the text the site shipped with,
from windsbraut/templates/windsbraut/impressum.html. That branch is what
keeps a legally required url answering on the day a new deployment lands before
anybody has opened the dashboard, so keep the names in it right. Draft means
here what it means everywhere else on the site: the editors see the page, the
public keeps reading the shipped text until it is published - and from that day
/impressum is in bokstaever's own page sitemap, which is why
windsbraut/sitemaps.py stops listing the route itself.
The page carries its own image, so the header photograph is the editors'
choice; the fallback keeps the one it always had. Markdown has no <address>
in it - set the page's type to "Markdown with inline HTML" to keep the postal
address marked up as one.
The slots are declared in windsbraut/apps.py and read in
windsbraut/content.py, default/index.html and the footer in
default/base.html. bokstaever's markdown keeps hard line breaks, so each
paragraph has to be written as one line.
The front page also shows the next three dates from the calendar the Verein
keeps at kalender.windsbraut-ev.de, read from its iCalendar feed by
windsbraut/calendar.py. The feed is fetched at most twice a day and a failure
is remembered for five minutes, because the request that finds the entry cold
is the one that waits for it; a calendar that cannot be reached simply leaves
the block off the page. The dates are read for the first page only, which is
the only one that draws them.
What crawlers and link previews see
The document head is one tag. {% seo %} in base.html renders the title, the
meta description, the canonical url, the Open Graph and Twitter tags, the feed
link and the JSON-LD, and works all of it out of what the view is showing - a
post and a page describe themselves out of their own text and their own image,
the index and the paginated pages out of BOKSTAEVER_SITE_DESCRIPTION. Nothing
in this repository writes a <title> any more.
The views that show no object say so themselves, in windsbraut/views.py:
class ImpressumView(StaticPageView):
title = "Impressum"
description = "Kontakt und Anbieterkennzeichnung des Windsbraut e.V., ..."
The title is the subject only; the tag appends the site name. ImpressumView
pushes that pair only while there is no impressum page to show; once there is
one it puts the row in the context instead and the tag describes the page out
of the page, like every other page. Those two urls are also the only public
ones of the site bokstaever does not know it serves, so
windsbraut/sitemaps.py adds them to bokstaever's sitemap and
windsbraut/urls.py shadows the sitemap.xml route to serve it - the same
move the front page makes for the index. The Impressum leaves that list the day
the page is published, since bokstaever lists the page at the same url from
then on.
BOKSTAEVER_SITE_URL is the origin all of that is written in terms of. It is
set in the Dockerfile rather than by the deployment, because it is the site's
own address. Unset, absolute urls follow whichever Host header a request
arrived with and the page cache splits per host; manage.py check --deploy
warns about it.
Container image
The image builds on top of the bokstaever image:
podman build --tag bokstaever /path/to/bokstaever
podman build --build-arg BASE_IMAGE=localhost/bokstaever --tag windsbraut .
The image sets what belongs to the site itself: BOKSTAEVER_EXTENSION, the
site name, description and url, the image feed, language and time zone. The
deployment provides the rest of bokstaever's environment (DATABASE_URL,
DJANGO_SECRET_KEY, DJANGO_ALLOWED_HOSTS, DJANGO_CSRF_TRUSTED_ORIGINS,
DJANGO_REDIS_QUEUE, CACHE_URL, ...).
Development
Check the repository out next to bokstaever, build the stylesheets and the slides once and point bokstaever at the app:
uv sync && npm install && npm run build
uv run python resize.py assets/slides windsbraut/static/windsbraut/assets
cd ../bokstaever/src
PYTHONPATH=../../windsbraut-site BOKSTAEVER_EXTENSION=windsbraut \
BOKSTAEVER_IMAGE_FEED=true uv run python manage.py runserver
npm run dev in a second shell rebuilds the bundles as they change. The build
has to have run at least once before bokstaever's collectstatic, since the
templates resolve their assets through the manifest it writes.
git lfs pull is required for the slide originals and the film.
Code quality
npm run lint # eslint, prettier, djlint, ruff
npm run fix # the same, writing the fixes it can make
npm test # vitest, then pytest
npm run lint builds the bundles as one of its steps, which is the only thing
that compiles the stylesheets: stylelint parses SCSS but does not resolve
@use, so a renamed partial or a missing mixin is clean to every other check in
the list and fails at build time.
npm is the entry point for both languages, the python tools are invoked through
uv run. The python tests need neither a database nor the bokstaever checkout:
tests/conftest.py configures django by hand and registers four modules
standing in for what the app imports from the cms - the page model, the
fragments, the sitemap base class and the index view - which is the image this
app is installed into rather than a dependency of the repository. Everything they need is configured in pyproject.toml; ruff does the
import sorting and wraps at 88 while the lint limit is 120, and the two djlint
rules that look for a <title> and a meta description in the template are
switched off there, with the reason.
Content migration
The content of the previous installation (bokstaever 2020, template bundle)
is imported with bokstaever's import_legacy management command, see its
docstring for the export and the media layout it expects. Of the two pages that
were file pages there, Schiffsdaten is a template in this app and Impressum is
a published page again, under the slug the section above names. The greeting
and the menu were never pages on that installation either; they are
Textbausteine now and there is nothing to import into them.