Skrift CMS Development Guide
Skrift is a lightweight async Python CMS built on Litestar, featuring WordPress-style template resolution, a hook/filter extensibility system, and SQLAlchemy async database access.
Current Project State
Configuration:
!cat app.yaml 2>/dev/null || echo "No app.yaml found"
Controllers:
!ls skrift/controllers/*.py 2>/dev/null | head -10
Models:
!ls skrift/db/models/*.py 2>/dev/null | head -10
Services:
!ls skrift/db/services/*.py 2>/dev/null | head -10
Templates:
!ls templates/*.html 2>/dev/null | head -10 || echo "No custom templates"
Quick Reference
Core Architecture
- Framework: Litestar (async Python web framework)
- Database: SQLAlchemy async with Advanced Alchemy
- Templates: Jinja2 with WordPress-style template hierarchy + optional themes (see
/skrift-theming)
- Config: YAML (app.yaml) + environment variables (.env)
- Auth: OAuth providers + role-based permissions (see
/skrift-auth)
- Forms: Pydantic-backed with CSRF (see
/skrift-forms)
- Hooks: WordPress-style extensibility (see
/skrift-hooks)
- Notifications: Real-time SSE with pluggable backends (see
/skrift-notifications)
- Observability: Optional Logfire tracing and structured logging (see
/skrift-observability)
- OAuth2 Server: Hub/spoke identity federation (see
/skrift-oauth2)
AppDispatcher Pattern
┌─────────────────────────────┐
│ AppDispatcher │
│ (skrift/asgi.py) │
└─────────────┬───────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌────────────────┐ ┌────────────┐ ┌────────────────┐
│ Setup App │ │ /static │ │ Main App │
│ (/setup/*) │ │ Files │ │ (everything) │
└────────────────┘ └────────────┘ └────────────────┘
setup_locked=False: /setup/* routes active, checks DB for setup completion
setup_locked=True: All traffic goes to main app, /setup/* returns 404
- Main app is lazily created after setup completes (no restart needed)
- Entry point:
skrift.asgi:app (created by create_dispatcher())
Key Files
| File | Purpose |
|---|
skrift/asgi.py | AppDispatcher, app creation, middleware loading |
skrift/config.py | Settings management, YAML config loading |
skrift/cli.py | CLI commands (serve, secret, db) |
skrift/middleware/ | Security headers middleware |
skrift/lib/hooks.py | WordPress-like hook/filter system |
skrift/lib/template.py | Template resolution with fallbacks and theme support |
skrift/lib/theme.py | Theme discovery, metadata parsing |
skrift/db/base.py | SQLAlchemy Base class (UUIDAuditBase) |
skrift/forms/ | Form system (CSRF, validation, rendering) |
skrift/auth/ | Guards, roles, permissions |
skrift/lib/notifications.py | Real-time notification service (SSE) |
skrift/lib/notification_backends.py | Pluggable backends (InMemory, Redis, PgNotify) |
skrift/lib/observability.py | Logfire observability facade (tracing, logging) |
skrift/db/models/notification.py | StoredNotification model for DB-backed backends |
Configuration System
.env (loaded early) → app.yaml (with $VAR interpolation) → Settings (Pydantic)
Environment-specific: app.yaml (production), app.dev.yaml (development), app.test.yaml (testing). Set via SKRIFT_ENV.
yaml
1db:
2 url: $DATABASE_URL
3 pool_size: 5
4 echo: false
5 schema: myschema # optional; PostgreSQL only — prefixes all tables
6
7auth:
8 redirect_base_url: "https://example.com"
9 providers:
10 google:
11 client_id: $GOOGLE_CLIENT_ID
12 client_secret: $GOOGLE_CLIENT_SECRET
13 scopes: ["openid", "email", "profile"]
14
15session:
16 cookie_domain: null
17
18theme: my-theme # default theme (overridden by admin UI)
19
20controllers:
21 - skrift.controllers.auth:AuthController
22 - skrift.controllers.web:WebController
23
24redis:
25 url: $REDIS_URL
26 prefix: "myapp"
27
28notifications:
29 backend: "" # empty = InMemoryBackend; or "module:ClassName"
30
31logfire:
32 enabled: true
33 service_name: my-site
34 console: true # prints spans to console
35
36security_headers:
37 content_security_policy: "default-src 'self'"
38
39middleware:
40 - myapp.middleware:create_logging_middleware
CLI Commands
bash
1skrift serve --reload --port 8080
2skrift serve --subdomain blog --port 8081 # serve single subdomain site
3skrift secret --write .env
4skrift db upgrade head
5skrift db downgrade -1
6skrift db revision -m "desc" --autogenerate
Database Layer
All models inherit from skrift.db.base.Base (provides id UUID, created_at, updated_at):
python
1from sqlalchemy import String, Text
2from sqlalchemy.orm import Mapped, mapped_column
3from skrift.db.base import Base
4
5class MyModel(Base):
6 __tablename__ = "my_models"
7 name: Mapped[str] = mapped_column(String(255), nullable=False)
8 description: Mapped[str | None] = mapped_column(Text, nullable=True)
Core Models
| Model | Table | Purpose |
|---|
User | users | User accounts |
OAuthAccount | oauth_accounts | Linked OAuth providers (access_token, refresh_token) |
Role | roles | Permission roles |
Page | pages | Content pages |
PageRevision | page_revisions | Content history |
Setting | settings | Key-value site settings |
StoredNotification | stored_notifications | Persistent notifications with mode column (Redis/PgNotify backends) |
Sessions injected via db_session: AsyncSession parameter in handlers.
Content Negotiation
Page views support Accept: text/markdown — returns raw page.content instead of rendered HTML. Works for all page types (WebController and page type factory routes).
Creating a Controller
python
1from litestar import Controller, get, post
2from litestar.response import Template as TemplateResponse
3from sqlalchemy.ext.asyncio import AsyncSession
4
5class MyController(Controller):
6 path = "/my-path"
7
8 @get("/")
9 async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
10 items = await item_service.list_items(db_session)
11 return TemplateResponse("items/list.html", context={"items": items})
Register in app.yaml:
yaml
1controllers:
2 - myapp.controllers:MyController
Creating a Service
python
1from sqlalchemy import select
2from sqlalchemy.ext.asyncio import AsyncSession
3
4async def get_by_id(db_session: AsyncSession, item_id: UUID) -> MyModel | None:
5 result = await db_session.execute(select(MyModel).where(MyModel.id == item_id))
6 return result.scalar_one_or_none()
7
8async def create_item(db_session: AsyncSession, name: str) -> MyModel:
9 item = MyModel(name=name)
10 db_session.add(item)
11 await db_session.commit()
12 await db_session.refresh(item)
13 return item
Template Resolution
WordPress-style hierarchy with fallbacks and optional theme support:
python
1from skrift.lib.template import Template
2
3# Tries: page-about.html -> page.html
4template = Template("page", "about")
5
6# Tries: post-news-2024.html -> post-news.html -> post.html
7template = Template("post", "news", "2024")
8
9# Without theme
10return template.render(TEMPLATE_DIR, page=page)
11
12# With theme (searches theme dirs first)
13return template.render(TEMPLATE_DIR, theme_name="my-theme", page=page)
Search order (with active theme):
themes/<active>/templates/ (active theme)
./templates/ (project root — user overrides)
skrift/templates/ (package — defaults)
Template globals: now(), site_name(), site_tagline(), site_copyright_holder(), site_copyright_start_year(), active_theme(), themes_available(). Filter: markdown.
For full theming details, see /skrift-theming.
Middleware
python
1from litestar.middleware import AbstractMiddleware
2
3class LoggingMiddleware(AbstractMiddleware):
4 async def __call__(self, scope, receive, send):
5 if scope["type"] == "http":
6 print(f"Request: {scope['method']} {scope['path']}")
7 await self.app(scope, receive, send)
8
9def create_logging_middleware(app):
10 return LoggingMiddleware(app=app)
Register in app.yaml:
yaml
1middleware:
2 - myapp.middleware:create_logging_middleware
3 - factory: myapp.middleware:create_rate_limit
4 kwargs:
5 requests_per_minute: 100
Security
skrift/middleware/security.py — ASGI middleware injecting CSP, HSTS, X-Frame-Options, etc. Configured via SecurityHeadersConfig. Pre-encoded at creation time. HSTS excluded in debug mode.
Static files: /static/ with same priority as templates (project root, then package).
Error Handling
Custom exception handlers in skrift/lib/exceptions.py. Templates: error.html, error-404.html, error-500.html.
Testing
python
1from litestar.testing import TestClient
2
3async def test_list_items(client, db_session):
4 item = await item_service.create(db_session, name="Test")
5 response = client.get("/items")
6 assert response.status_code == 200
7 assert "Test" in response.text
Related Skills
For deep-dive guidance on specific subsystems:
/skrift-hooks — Hook/filter extensibility, custom hook points, built-in hooks
/skrift-forms — Form system, CSRF, field customization, template rendering
/skrift-auth — OAuth flow, guard system, roles, permissions
/skrift-theming — Theme discovery, template/static resolution, per-request switching, RESOLVE_THEME hook
/skrift-notifications — SSE protocol, pluggable backends, group keys, dismiss patterns
/skrift-observability — Logfire integration, structured tracing, instrumentation
/skrift-oauth2 — OAuth2 Authorization Server, hub/spoke identity federation, Skrift provider