Multisite CMS with htmx, plugins, and SEO baked in.
Project description
bragi
A multisite CMS built with Python, Flask, and htmx. Markdown source of truth, plugin-extensible from day one, SEO as a first-class citizen.
Status
Latest release: 1.27.4 (2026-06-05).
Functional surface today: multisite CMS with markdown source-
of-truth, TipTap editor (with image size / alignment classes and
a bubble menu for inline picks), structured CV / resume page kind
(PageKind.RESUME with typed sections, Project↔Position linkage,
schema.org microdata, print-friendly), auto-derived public
navigation from the page tree (with per-page show_in_nav and
menu_order controls), GitHub OAuth + local
bootstrap, redirects as a first-class subsystem, importers for
Hugo / Ghost / WordPress / LinkedIn, attachments + media library with
theme-aware multi-format multi-width renditions (<picture> with
AVIF / WebP / fallback tiers and per-class sizes), Unsplash
plugin (search + insert from the admin image picker, photographer
credit auto-renders beneath inline-body images; requires
BRAGI_UNSPLASH_ACCESS_KEY), pinned posts on the landing page,
ActivityPub + webmentions, four in-tree themes with auto
light/dark, sitemap / feed / JSON-LD, audit-driven hardening
from v1.12 through v1.19.
See CHANGELOG.md for per-release detail.
Releases follow git-flow with develop as the default branch.
Container images ship to GHCR as bragi-admin:vX.Y.Z and
bragi-delivery:vX.Y.Z on every GitHub Release, as multi-arch
manifest lists covering linux/amd64 and linux/arm64. The
release.yml workflow publishes the PyPI wheel first, then builds
images that consume it via pip install bragi-cms==X.Y.Z. docker pull resolves the right variant for the host architecture
automatically; Apple Silicon laptops, Ampere / Graviton servers,
and ARM homelabs run natively rather than through QEMU emulation.
What bragi is
- Multisite by design. One database serves many sites; the Host
header at the WSGI edge resolves to a Site row. Every content
table has a
site_idFK. - Sites are first-class workspaces. Each site has a designated
owner (with implicit-admin power) and a collaborator roster.
Admin content lives under
/admin/sites/<slug>/...(posts, pages, redirects, attachments, analytics, team), with a per-site dashboard and a picker that auto-redirects single-site users into their workspace. Cross-site id probes return 404 so the response code can't be used to enumerate other sites' content. - Two-binary architecture.
bragi-admin(editor UI, write API) andbragi-delivery(read-only public renderer) share one DB and one plugin manager; only the middleware stacks and registered Blueprints differ. Admin runs on its own subdomain. - htmx as the render strategy. Server-rendered HTML always;
partial swaps via the
HX-Requestheader. No SPA, no client-side routing, no separate prerender step. Crawlers see complete pages. - Markdown source of truth. Post and Page bodies persist as
markdown text with a cached HTML render alongside. TipTap (with
its markdown serializer) is the admin editor; the data model is
editor-independent. CommonMark + tables out of the box; the
markdown_extrasbuilt-in plugin adds footnotes (text[^id]+[^id]: body), KaTeX-compatible math ($x$/$$x$$), and Mermaid code fences (```mermaid). Plugins can register more extensions via theregister_markdown_extensionhookspec. - Post-page chrome. Each post renders with an author byline,
reading-time estimate (220 WPM, rounded up), and an
"Updated YYYY-MM-DD" line that only appears when the edit is
meaningfully after first publish. Optional
User.biotext surfaces as an "About the author" aside below the body. A table of contents auto-renders for multi-section posts (h2 / h3 headings). - Related posts at end of article. Tag-overlap ranks
same-site published posts ("more shared tags wins, recency
ties"); rendered as a "You may also like" aside under the body.
Per-site count override via
/admin/sites/<slug>/settings/(related_posts_count, default 3); zero-tag posts render no aside. - Chronological archive.
<post_index>/archive/lists years with counts (newest first); drilling in shows months for that year, then posts in that month (oldest first, journal-style). Drafts are excluded; out-of-range or empty buckets 404. Each level carries the standardETag+Last-Modifiedvalidators so feed readers and crawlers get cheap 304s. - Auto-navigation from the page tree. Published pages
appear in the public-site header nav by default, ordered by
a per-page
menu_order. Direct children become a native<details>submenu. The per-page checkbox opts an individual page out; the page mapped to the site's home is auto-hidden so the brand link is not duplicated. - Plugin-extensible from day one. Built-ins (Post, Page,
redirects, importers, analytics, ...) register through the
bragi.pluginsentry-point group, the same path third parties use. No internal fast path. - SEO as a first-class citizen. Per-page title / meta /
canonical / JSON-LD editable in admin. Open Graph + Twitter
Card meta on every post and page (with a per-post / per-page
attachment override and a per-site default OG image), so
social shares render rich previews. Per-site
sitemap.xml,robots.txt,security.txt. Atom 1.0 feeds at/feed.xml(whole site) and<post_index>/<tag_segment>/<slug>/feed.xml(per tag). Server-side Pygments highlighting for code blocks (Ansible / Python / Terraform lexers in core). - Redirects as a core subsystem. Slug renames auto-301;
importers preserve source URLs as redirect rows; resolution
middleware runs on every public 404.
410 Gonefor tombstoned content. - Revision history. Every post / page save captures a
pre-edit snapshot in
post_revisions/page_revisions. Admin views list revisions, show a side-by-side with the live row, and restore (with the restore itself recorded as a fresh revision so it stays reversible). - HTTP caching baked in. Delivery 2xx HTML carries
Cache-Control(short browser cache, longer shared cache), weakETag, andLast-Modified;If-None-Match/If-Modified-Sinceshort-circuit to 304. Admin forcesprivate, no-store. Theon_cache_purgeplugin hookspec fires on every content commit so a CDN invalidator has something to subscribe to. - Push-crawl via IndexNow. Post / page publish, update, and
delete fire a fire-and-forget POST to the configured IndexNow
endpoint so participating search engines (Bing, Yandex, Seznam,
Naver, ...) hear about the change immediately. Per-site key
bootstrapped with
bragi indexnow setup --site <slug>; the verification key file lives at/<key>.txton the delivery app. - Programmatic posting via API tokens. Personal access
tokens at
/admin/account/tokens/(list / create / revoke; plaintext shown once on create) authenticate scripts and bots viaAuthorization: Bearer brg_<id>_<secret>. The JSON REST surface at/admin/api/sites/<slug>/posts/covers GET list, POST create, PATCH update, and POST publish, scope-gated bypost:write. Argon2id-hashed at rest; expiry honoured; every use recorded in the audit log. - Indieweb webmentions (send + receive). Outbound: on
publish, every external link in a post is queued; the
cron-driven
bragi webmentions send-pendingperforms W3C endpoint discovery (Link header, then<link rel="webmention">) and POSTs the mention. Inbound:POST /webmentionson the delivery app validates the source actually links to the target, extracts an h-card author shape, and stores the mention pending admin moderation. Approved rows render in a "Mentioned by" aside under the post; discovery<link rel="webmention">is injected into the delivery<head>automatically. - ActivityPub federation (one actor per site). Each site
is a follow-able fediverse actor addressed as
@<site-slug>@<hostname>. Endpoints (delivery app):/.well-known/webfinger,/actor,/actor/inbox,/actor/outbox,/actor/followers. Mastodon-compatible HTTP signatures (RSA-SHA256, draft-cavage-12) on outbound POSTs; inboundFollow/Undo Followverified against the sender's public key. On post publish, a Create+Note fans out to every follower;bragi activitypub send-pendingships the queued deliveries. Per-site keypair generated on first/actorhit or viabragi activitypub keygen --site <slug>.
What bragi is not
- Not multilingual at the post level. Each
Sitehas onelocale; per-post translations are not supported. - Not a SaaS / multi-tenant cloud product. Single-operator, with multiple sites under that operator.
- Not a block-tree editor. Markdown is the source of truth.
- Not a real-time / collaborative editor.
Stack
- Python 3.12+
- Flask 3.x
- SQLAlchemy 2.0 + alembic
- Pydantic Settings
- pluggy (plugin framework)
- Authlib (GitHub OAuth + future OIDC providers)
- markdown-it-py + Pygments
- htmx (delivery side) + TipTap (admin editor)
- SQLite (WAL) primary store; DuckDB reserved for later dataset paths
- gunicorn (production WSGI server, sync worker class)
Importers
All four ship in 1.x and are idempotent via Post.source_id,
so re-running the importer over an updated source updates rows
in place rather than duplicating them.
- Hugo: walks
content/**/*.md(skipping_index.md), parses TOML or YAML frontmatter, and copies the markdown body through verbatim. The same bragi markdown pipeline that runs on native authoring then renders it, so no shortcode translation step is needed. Everyaliases:entry becomes a 301 Redirect from the legacy URL to the post's bragi canonical under the site'spost_indexpage (e.g./blog/<slug>/when the site's post index lives at/blog/); fragments and query strings on the alias are stripped before matching. Sites with nopost_indexpage have no public post URLs, so the importer skips the redirect emission for those.tags:lists upsert by slug. CLI:bragi import hugo --site <slug> [--author <email>] [--dry-run] <path>. - Ghost: parses the single-file JSON export
(
db[0].data.posts). Posts and pages both land: posts become bragi Posts; pages become bragiSTATICpages with slug, title, body, andmeta_titlepreserved. Bodies arrive as HTML and convert to markdown viamarkdownify(heading_style="ATX"); tags come fromdata.tags+data.posts_tags; authors match existing Users by email (else fall back to the first user). For every published post a 301 lands from Ghost's permalink (/<slug>/) to bragi's canonical under the site'spost_indexpage (e.g./blog/<slug>/) so legacy bookmarks survive. Featured images (feature_image, orog_imageas fallback) are downloaded as bragiAttachmentrows;feature_image_altbecomes the alt text. Additional fields picked up:featuredsetsis_pinnedon posts. Failed image downloads warn and continue without the image. CLI:bragi import ghost --site <slug> [--author <email>] [--dry-run] <path>. - WordPress: parses WXR (WordPress eXtended RSS) XML
exports.
wp:post_type=postrows become Posts,pagerows become Pages; bodies are converted from WordPress HTML to markdown and run through the same pipeline. Categories and tags upsert by slug; authors match by email or fall back to the first user. Permalinks captured at export time become 301 redirects to the bragi canonical (posts resolve through the site'spost_indexpage; pages resolve through the static-page chain). Idempotency keys on(site_id, source_id)viawp:post_id. CLI:bragi import wordpress --site <slug> [--author <email>] [--dry-run] <wxr.xml>. - LinkedIn (
bragi.contrib.import_linkedin). Reads the seven resume-relevant CSVs in LinkedIn's "Download your data" export ZIP and populates a Resume page'sresume_datavia a two-phase plan-review-apply flow. The plan emits oneChangeProposalper concrete diff (add / update / remove); the operator approves a subset by editing the JSON plan file or by checking boxes on the admin review page. Re-imports preserve operator-authored narrative fields (description_markdown,impacts,body_markdown,header.profile_links,highlights) across matched rows; position-matching uses(company, role, start_date)so renamed titles surface as a remove+add pair the operator can spot and reject. CLI:bragi import linkedin <zip> --site <slug> [--page-slug cv] [--plan-out PATH]to plan;bragi import linkedin --apply <plan.json>to apply the filtered subset. Admin UI: upload widget on every resume page edit form, with a review page rendered after the upload.
Notion, Substack, and Medium importers are deferred to follow-up packages; no v1.x commitment.
The admin now carries a site-scoped Import page at
/admin/sites/<slug>/import/ that lists every importer wired
up with an admin form. Ghost is the first wired importer there,
with a plan-then-apply browser flow (upload → review → apply or
cancel) that mirrors LinkedIn's. The CLI invocations above
continue to work; the admin route is an alternative surface for
operators who prefer the browser. Hugo and WordPress remain
CLI-only for now; they'll grow admin tiles in follow-up PRs via
the new register_importer_admin_tile hookspec.
Export (portability)
bragi export [--site <slug>] [--output <dir>]
writes a Hugo-shaped tree per site: posts as
content/posts/<slug>.md with YAML frontmatter, pages under
content/pages/, attachment bytes under static/attachments/
alongside an attachments.csv metadata manifest, and the
per-site redirect table as redirects.csv. Default output is
bragi-export-YYYYMMDD-HHMMSS/ in the CWD.
Output is deterministic: re-running against an unchanged DB
yields byte-identical files, so a periodic bragi export doubles
as a diffable snapshot. Posts round-trip through bragi import hugo: importing the export and re-exporting changes nothing
beyond timestamps, so the corpus is portable back into any Hugo
build at any time.
Backups
bragi backup [--output PATH]
writes a single .tar.gz containing a consistent SQLite snapshot
(produced with VACUUM INTO, so no companion -wal / -shm
files) plus the contents of Settings.attachments_root as
attachments/. Default output: bragi-backup-YYYYMMDD-HHMMSS.tar.gz
in the current working directory.
To restore: extract the tarball, drop bragi.db and
attachments/ into a fresh deployment (matching paths), and
restart the admin + delivery processes. There is no restore
subcommand by design; a tool that overwrites a live deployment
is a big risk for not much help.
bragi backup is SQLite-only and exits 2 with a clear message
under a non-SQLite BRAGI_DATABASE_URL (its VACUUM INTO is
SQLite-specific). Postgres operators: use pg_dump for the
DB half and a separate tar of attachments_root for the file
half. bragi db vacuum follows the same gate (PRAGMA wal_checkpoint(TRUNCATE) is SQLite-only); on Postgres use
VACUUM (FULL) or your usual autovacuum tooling instead.
Quick start (development)
poetry install
poetry run alembic upgrade head
make dev # runs bragi-admin on :8001 and bragi-delivery on :8002 via the in-repo Procfile supervisor
Then:
- Admin: http://127.0.0.1:8001/
- Delivery preview: http://127.0.0.1:8002/ (with a configured Site hostname resolving to localhost)
Lint, type-check, and test:
make lint
make typecheck
make test
Quick start (production / docker compose)
The repo ships an example compose.yml that pulls
the published images from GHCR. The tag is parameterised via
BRAGI_TAG (default latest); pin to a specific release in
production:
BRAGI_TAG=v1.27.4 BRAGI_SECRET_KEY="$(openssl rand -hex 32)" docker compose up -d
A bragi-tasks sidecar owns bragi db upgrade on start
(touching /data/.migrated once the schema is current), then
enters a sleeper loop that dispatches periodic CMS commands:
scheduled-publish (flips drafts whose scheduled_for has
elapsed), embeds rerender-pending, webmentions send-pending,
activitypub send-pending, db analyze (daily), and db vacuum
(weekly). The admin and delivery services gate their start on
the sidecar's healthcheck, so a fresh deploy and a schema-bump
deploy work the same way. Each web container also exposes its
own /healthz endpoint that does a SELECT 1 round-trip; the
compose healthcheck stanza watches both so a wedged worker
restarts via restart: unless-stopped. The shared bragi-data
volume backs /data/bragi.db, /data/uploads/ (attachments),
and the /data/.migrated sentinel; back it up. Ports bind to
127.0.0.1 only; front the apps with a reverse proxy
(Caddy / nginx / Traefik) for TLS and hostname routing.
BRAGI_ENV=production (set on both web services in the example
compose) tells the app it's running in production. When set,
booting with the bundled dev SECRET_KEY is fatal rather than
just logging a warning, so a misconfigured BRAGI_SECRET_KEY
fails loud instead of running with a predictable signing key.
Leave unset for local dev.
BRAGI_TRUSTED_PROXY_HOPS (default 0; the example compose sets
it to 1) tells the apps how many trusted reverse-proxy hops sit
in front of them. When > 0, both create_admin_app and
create_delivery_app wrap the WSGI callable in
werkzeug.middleware.proxy_fix.ProxyFix(x_for, x_proto, x_host)
with that hop count. Without it, three breakages manifest on a
fresh prod deploy: (a) url_for(..., _external=True) for the
GitHub OAuth redirect_uri emits http://... and GitHub
rejects the callback; (b) every AuditLog.ip and Session.ip
row records the reverse proxy's IP, hiding real client IPs;
(c) per-IP analytics groups every visit under the proxy.
Never set this higher than the actual reverse-proxy depth:
each unit of trust extends the X-Forwarded-* spoofability
boundary one hop outward.
Container runtime hardening already in the published images:
both admin and delivery run as a non-root bragi user
(--uid 1000, pinned identically across the two so the shared
/data volume is writable from both); gunicorn ships with
--graceful-timeout 25 paired with stop_grace_period: 30s
on the compose services so an in-flight outbound POST
(webmention sender, AP delivery) has up to 25 s to return on
docker compose stop before SIGKILL fires; the bragi-tasks
sidecar retries bragi db upgrade with backoff
(ALEMBIC_MAX_ATTEMPTS=5, ALEMBIC_RETRY_DELAY=15, in seconds) and exits
0 after exhausting attempts so a broken migration shows as a
clean Exited (0) rather than livelocking the deploy.
BRAGI_MAX_REQUEST_BYTES (default 1 MiB) caps the request body
size to protect the federation inboxes from streaming-body OOM.
On the admin app, this cap is automatically raised to
max(max_request_bytes, attachments_max_bytes + 64 KiB) so
attachment uploads up to BRAGI_ATTACHMENTS_MAX_BYTES
(default 20 MiB) still go through. Raise both knobs in lockstep
for larger uploads.
Both apps run under gunicorn inside the container (sync worker
class; --access-logfile - to stdout). Worker counts default to
2 for admin and 4 for delivery; tune via ADMIN_WORKERS /
DELIVERY_WORKERS env vars on each service if your traffic
shape needs it.
BRAGI_UNSPLASH_ACCESS_KEY (optional) enables the Unsplash
plugin: authors search Unsplash from inside the admin
attachments picker (and the TipTap "Insert image" button, which
opens the same picker), pick a photo, and the plugin downloads
it into bragi's storage as a regular attachment with the
photographer's name and profile URL stored alongside. On the
public page the credit auto-renders beneath inline-body
Unsplash images. Set the key on the admin service only; the
delivery service doesn't talk to Unsplash. Get a key from
https://unsplash.com/developers. Leave unset to disable the
plugin (the Unsplash tab stays hidden). BRAGI_UNSPLASH_APP_NAME
(default bragi) controls the utm_source tag on credit links;
if you customise it, set it on both admin and delivery for
consistency.
Task-runner cadences (all in seconds, set on the bragi-tasks
service) default to SCHEDULED_PUBLISH_EVERY=60,
EMBEDS_RERENDER_EVERY=600, WEBMENTIONS_SEND_EVERY=300,
ACTIVITYPUB_SEND_EVERY=60, ANALYZE_EVERY=86400,
VACUUM_EVERY=604800. Override in compose.yml if a different
rhythm suits your workload. The webmentions / ActivityPub
cadences only do work when there are queued rows; a site that
hasn't enabled either plugin pays nothing per tick.
Project layout
bragi/
├── src/bragi/
│ ├── api.py # public plugin API
│ ├── hookspecs.py # internal hookspec definitions
│ ├── plugins.py # PluginManager + entry-point discovery
│ ├── settings.py # Pydantic Settings
│ ├── cli.py # `bragi` top-level click group
│ ├── apps/
│ │ ├── admin.py # create_admin_app
│ │ └── delivery.py # create_delivery_app
│ ├── core/ # shared, non-plugin code
│ │ ├── models/ # SQLAlchemy models (single source of truth)
│ │ ├── middleware/ # site_resolver, csrf, sessions, redirects
│ │ ├── render/ # markdown + transform registries
│ │ ├── audit.py # AuditLog writer
│ │ ├── breadcrumbs.py # Crumb dataclass + set_breadcrumbs helper (admin nav)
│ │ ├── cache.py # Cache-Control / ETag / 304 helpers
│ │ ├── db.py # SessionLocal
│ │ ├── export.py # corpus export writer (bragi export)
│ │ ├── feed.py # Atom feed builder
│ │ ├── healthz.py # /healthz handler
│ │ ├── htmx.py # HX-Request dispatch helpers
│ │ ├── http.py # hardened outbound fetcher (safe_get / safe_post)
│ │ ├── image_processor.py # image transform helpers
│ │ ├── permissions.py # per-site role enforcement
│ │ ├── registry.py # in-process Registry (content types, importers, nav, ...)
│ │ ├── renditions.py # attachment rendition pipeline
│ │ ├── safe_urls.py # safe_external_url + IDN gate
│ │ ├── security.py # current_user / is_superuser
│ │ ├── seo.py # title/meta/canonical/og helpers
│ │ ├── storage.py # attachment storage backend
│ │ ├── text.py # slugify + unique-slug helpers (post/page collision-aware)
│ │ ├── themes.py # ThemeAwareLoader + theme registry
│ │ ├── time.py # aware_utcnow
│ │ ├── url.py # URL helpers
│ │ └── useragent.py # bot / browser / feed-reader classifier
│ ├── alembic/ # migrations bundled in wheel (bragi:alembic)
│ └── contrib/ # built-ins as plugins
│ ├── activitypub/ # one fediverse actor per site (follow / undo / outbox fanout)
│ ├── admin_imports/ # site-scoped admin importer index (tile aggregator)
│ ├── analytics/ # per-site pageview sink + admin dashboard
│ ├── anchors/ # heading id injection
│ ├── api_tokens/ # personal access tokens + JSON REST surface
│ ├── attachments/ # upload + serve + media library
│ ├── audit/ # audit-log admin
│ ├── auth_github/ # OAuth via Authlib
│ ├── auth_local/ # email + password + must-change rotation
│ ├── embeds/ # external-content embeds (directive + providers + rerender)
│ ├── highlight/ # Pygments html transform
│ ├── import_ghost/ # Ghost JSON / ZIP importer (posts, pages, featured images)
│ ├── import_hugo/ # Hugo content-tree importer
│ ├── import_linkedin/ # LinkedIn data export (ZIP) importer for resume pages
│ ├── import_wordpress/ # WordPress WXR XML importer
│ ├── indexnow/ # IndexNow push-crawl on publish/update/delete
│ ├── internal_links/ # [text](post:42) save-time + delivery-time resolver + admin picker
│ ├── markdown_extras/ # bundled markdown-it extensions (footnotes, ...)
│ ├── nav/ # auto-derived public site navigation from the page tree
│ ├── page/ # nested page content type
│ ├── post/ # post content type + tags + tiptap editor
│ ├── redirects/ # resolve_redirect + admin + slug-change auto-301
│ ├── search/ # SQLite FTS5 over post + page bodies
│ ├── seo/ # sitemap, robots.txt, security.txt, feed.xml
│ ├── sessions/ # session admin (list / revoke)
│ ├── sites/ # Site CRUD admin + alias subcommands
│ ├── team/ # per-site team management (list / grant / revoke)
│ ├── theme_default/ # in-tree default theme (registers slug "default")
│ ├── theme_minimal/ # lean, content-first theme (slug "minimal")
│ ├── theme_serif/ # long-form reading theme (slug "serif")
│ ├── theme_terminal/ # monospace dev-focused theme (slug "terminal")
│ ├── themes/ # file-based theme registry + admin picker
│ └── webmentions/ # indieweb send + receive + admin moderation
├── alembic/ # alembic.ini + dev shim (script_location = bragi:alembic)
├── docker/ # admin.Dockerfile, delivery.Dockerfile
├── .github/workflows/ # ci.yml, docker.yml, release.yml
└── tests/
├── unit/ # pure logic, no DB
├── contrib/ # one file per built-in plugin
├── core/ # core middleware / cache / permissions tests
├── integration/ # full stack lifecycle scenarios
└── test_*.py # cross-cutting smoke (CLI, hookspecs, plugin set)
Authoring a third-party theme
A theme is a plain Python package that registers a ThemeSpec via
the register_theme hook on the bragi.plugins entry-point group.
Same surface the in-tree theme_default / theme_minimal /
theme_serif / theme_terminal use; nothing internal-only.
Distribution name. Follow the bragi-theme-<slug> convention
(e.g. bragi-theme-coral). It keeps third-party packages
greppable on PyPI and signals theme-package shape without further
inspection. The Python import name is independent (coral_theme,
bragi_theme_coral, whatever you like); only the distribution
name follows the convention.
Package layout.
bragi-theme-coral/
├── pyproject.toml
├── README.md
└── coral_theme/
├── __init__.py
├── plugin.py
├── templates/
│ └── delivery/
│ └── base.html
└── static/ # optional
└── theme.css
plugin.py (the whole file).
from __future__ import annotations
from pathlib import Path
import jinja2
from bragi.api import ThemeSpec, hookimpl
@hookimpl
def register_theme() -> ThemeSpec:
return ThemeSpec(
slug="coral",
display_name="Coral",
template_loader=jinja2.PackageLoader("coral_theme", "templates"),
# Drop `static_dir` if your theme inlines its CSS in
# `delivery/base.html` (the in-tree themes do).
static_dir=Path(__file__).parent / "static",
)
pyproject.toml entry-point declaration.
[project.entry-points."bragi.plugins"]
coral_theme = "coral_theme.plugin"
The entry-point name (coral_theme above) must be unique across
every plugin installed in the deployment; bragi's runtime fails
loud on collision (#188). Pick a name that includes your slug so
the bragi plugins list output (#190) reads naturally.
Required template: delivery/base.html. Bragi resolves
delivery/base.html against your theme first (via
ThemeAwareLoader) for every Site that selected your slug. Your
template must preserve the block surface every content-type
template extends:
| Block | Purpose |
|---|---|
title |
<title> content |
meta |
description / canonical / robots meta tags |
feed_links |
Atom <link rel="alternate"> |
social_meta |
Open Graph + Twitter Card meta (content templates override) |
jsonld |
JSON-LD <script> (content templates override) |
content |
the page body |
Plus the Jinja globals plugins register: pygments_css_url,
webmention_endpoint_url, etc. Easiest path: copy
bragi.contrib.theme_default's delivery/base.html as your
starting scaffold and restyle from there.
Optional templates: anything under delivery/. A theme that
ships delivery/post_detail.html shadows the post plugin's
default, etc. Override only the templates you actually want to
change; the rest fall through to the plugin's own
templates/delivery/.
Static assets. If static_dir is set, the delivery app
serves your files at /theme/<slug>/static/<path>. Reference
them from your templates with that URL:
<link rel="stylesheet" href="/theme/coral/static/theme.css">
The path is reserved; bragi.contrib.themes owns the
blueprint that serves it.
Automatic light / dark. The in-tree themes all use the
@media (prefers-color-scheme: dark) pattern with CSS custom
properties. Recommended:
<meta name="color-scheme" content="light dark">
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #222222;
/* ... */
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #0d0d0d;
--fg: #f3f4f6;
/* ... */
}
}
</style>
Cribbed verbatim from bragi.contrib.theme_minimal; pick the
palette that suits your theme.
Installing. Install your package into the same Python
environment as bragi (the admin and delivery containers, or
poetry add in a dev tree):
pip install bragi-theme-coral
Restart both apps; the entry-point group is read at process
boot. Once installed, your slug appears in the admin theme
picker on the site-edit form, and bragi plugins list reports
your distribution name + version under "origin".
Activating. Per-Site selection via the admin site-edit
form, or set Site.theme = "coral" in the DB. NULL means "use
the bundled default theme"; an unknown slug falls back to
default with a logged warning rather than 500ing the page.
Disabling a bundled theme. Comment its line under
[project.entry-points."bragi.plugins"] in bragi's
pyproject.toml and rebuild the images; no internal fast path
keeps it around. Same mechanism for any bundled plugin.
Versioning and releases
The version lives in pyproject.toml (version field), read at
runtime via importlib.metadata and exposed as bragi.__version__.
Production images are tagged bragi-admin:vX.Y.Z and
bragi-delivery:vX.Y.Z on the GitHub Container Registry, built by
the docker.yml workflow on git tag push as multi-arch manifest
lists covering linux/amd64 and linux/arm64.
From v1.27.0, bragi is also published to PyPI as bragi-cms (the
bragi name is held by The Managarm Project's IDL):
pip install bragi-cms==1.27.4
The import path stays import bragi. Container images remain the
primary deploy artefact for operators who want pre-built images.
License
MIT. See LICENSE.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file bragi_cms-1.27.4.tar.gz.
File metadata
- Download URL: bragi_cms-1.27.4.tar.gz
- Upload date:
- Size: 452.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f01f807060eae3bb395f97ea1edeee98ecbed61332f1a446ce5bf200534c6fe9
|
|
| MD5 |
3b0e8f4935a800855883e13823d02a4a
|
|
| BLAKE2b-256 |
243a9a72157964b144583607e97349b323748853a7a87bf8a946e8d8949e075d
|
Provenance
The following attestation bundles were made for bragi_cms-1.27.4.tar.gz:
Publisher:
release.yml on sgaduuw/bragi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bragi_cms-1.27.4.tar.gz -
Subject digest:
f01f807060eae3bb395f97ea1edeee98ecbed61332f1a446ce5bf200534c6fe9 - Sigstore transparency entry: 1736005981
- Sigstore integration time:
-
Permalink:
sgaduuw/bragi@4ee64ae20675ffe5d8d4be39ff375699a3e13b04 -
Branch / Tag:
refs/tags/v1.27.4 - Owner: https://github.com/sgaduuw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4ee64ae20675ffe5d8d4be39ff375699a3e13b04 -
Trigger Event:
release
-
Statement type:
File details
Details for the file bragi_cms-1.27.4-py3-none-any.whl.
File metadata
- Download URL: bragi_cms-1.27.4-py3-none-any.whl
- Upload date:
- Size: 625.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2675e5604392d20d5444d507a6cc6ae8d919be66a252f79ec6c60a3a6c0cfdf9
|
|
| MD5 |
ed0f4ee29b41dfdeabbb10a3b9fb0a60
|
|
| BLAKE2b-256 |
975026b8f48d124c892bada5a072160b00b95af293742a9dbcaca7acb131315e
|
Provenance
The following attestation bundles were made for bragi_cms-1.27.4-py3-none-any.whl:
Publisher:
release.yml on sgaduuw/bragi
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
bragi_cms-1.27.4-py3-none-any.whl -
Subject digest:
2675e5604392d20d5444d507a6cc6ae8d919be66a252f79ec6c60a3a6c0cfdf9 - Sigstore transparency entry: 1736006046
- Sigstore integration time:
-
Permalink:
sgaduuw/bragi@4ee64ae20675ffe5d8d4be39ff375699a3e13b04 -
Branch / Tag:
refs/tags/v1.27.4 - Owner: https://github.com/sgaduuw
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@4ee64ae20675ffe5d8d4be39ff375699a3e13b04 -
Trigger Event:
release
-
Statement type: