Metadata-Version: 2.4
Name: django-themekit
Version: 0.1.0
Summary: Flexible theme engine, loader, and selector for Django.
Keywords: django,theme,themes,theme-engine,template-loader,template-resolution
Author: FIFOA Labs
Author-email: FIFOA Labs <labs@fifoa.com>
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Programming Language :: Python :: Implementation :: CPython
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Classifier: Typing :: Typed
Requires-Dist: django>=5.2,<6.1
Requires-Python: >=3.11
Project-URL: Homepage, https://github.com/fifoa-labs/django-themekit
Project-URL: Repository, https://github.com/fifoa-labs/django-themekit
Project-URL: Issues, https://github.com/fifoa-labs/django-themekit/issues
Description-Content-Type: text/markdown

# django-themekit

[![PyPI version](https://img.shields.io/pypi/v/django-themekit.svg)](https://pypi.org/project/django-themekit/)
[![Python versions](https://img.shields.io/pypi/pyversions/django-themekit.svg)](https://pypi.org/project/django-themekit/)
[![CI](https://github.com/fifoa-labs/django-themekit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/fifoa-labs/django-themekit/actions/workflows/ci.yml)
[![Coverage](https://codecov.io/gh/fifoa-labs/django-themekit/branch/main/graph/badge.svg)](https://codecov.io/gh/fifoa-labs/django-themekit)
[![License](https://img.shields.io/pypi/l/django-themekit.svg)](https://github.com/fifoa-labs/django-themekit/blob/main/LICENSE)

**Flexible theme engine, loader, and selector for Django.**

ThemeKit is a template-resolution engine for Django. It never creates themes,
builds CSS, manages assets, or dictates how a theme should look. It answers one
focused question:

> Given a Django template name, which existing template file should Django
> render for the active theme?

Most Django projects eventually need some form of theming. A site may need a
branded customer skin, a tenant-specific layout, a user-selected interface, a
preview theme, or a project-wide redesign that can fall back to the previous
implementation one template at a time.

Many projects solve that problem with local middleware, a custom template
loader, request state, and project-specific fallback rules. `django-themekit`
packages those responsibilities into a small, reusable Django integration.

- **PyPI:** https://pypi.org/project/django-themekit/
- **Source:** https://github.com/fifoa-labs/django-themekit
- **Issues:** https://github.com/fifoa-labs/django-themekit/issues
- **License:** MIT

---

## Contents

- [The core idea](#the-core-idea)
- [What ThemeKit does and does not do](#what-themekit-does-and-does-not-do)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start: one configured theme](#quick-start-one-configured-theme)
- [Recommended complete configuration](#recommended-complete-configuration)
- [Template layout conventions](#template-layout-conventions)
- [Exact template resolution order](#exact-template-resolution-order)
- [Root templates such as `base.html`](#root-templates-such-as-basehtml)
- [Template inheritance](#template-inheritance)
- [Partial themes and transparent fallback](#partial-themes-and-transparent-fallback)
- [Theme chains](#theme-chains)
- [Theme selection](#theme-selection)
- [Static selection with `THEMEKIT_THEME`](#static-selection-with-themekit_theme)
- [Dynamic selection with middleware](#dynamic-selection-with-middleware)
- [Session-based selection](#session-based-selection)
- [User-based selection](#user-based-selection)
- [Custom selectors](#custom-selectors)
- [Request-local state](#request-local-state)
- [Template context variables](#template-context-variables)
- [Debug response headers](#debug-response-headers)
- [Error templates](#error-templates)
- [Template loader configuration](#template-loader-configuration)
- [Project template directories](#project-template-directories)
- [Installed-app templates](#installed-app-templates)
- [Custom loaders](#custom-loaders)
- [Template caching](#template-caching)
- [Performance](#performance)
- [Settings reference](#settings-reference)
- [Public Python API](#public-python-api)
- [Django system checks](#django-system-checks)
- [Complete configuration recipes](#complete-configuration-recipes)
- [Testing your integration](#testing-your-integration)
- [Troubleshooting](#troubleshooting)
- [Migrating an existing local theme implementation](#migrating-an-existing-local-theme-implementation)
- [Internal architecture](#internal-architecture)
- [Security and trust boundaries](#security-and-trust-boundaries)
- [Non-goals](#non-goals)
- [Contributing](#contributing)
- [License](#license)

---

## The core idea

Assume a Django view renders the ordinary template:

```python
return render(request, "pages/home.html")
```

Without ThemeKit, Django resolves `pages/home.html` through its configured
template loaders.

With ThemeKit configured and the active theme set to `phoenix`, ThemeKit first
looks for optional themed overrides. A project can add either of these files:

```text
templates/pages/themes/phoenix/home.html
```

or:

```text
templates/themes/phoenix/pages/home.html
```

If neither override exists, Django still renders the original:

```text
templates/pages/home.html
```

The view does not change. The original template name does not change. The
ordinary template remains the final fallback.

This is the central guarantee of ThemeKit:

> Adding a theme never requires replacing the existing template structure.
> Themed templates are optional overrides layered on top of normal Django
> template loading.

A theme may override one template, several templates, only `base.html`, or an
entire application. Missing theme files are expected and are not errors as
long as the ordinary template exists.

---

## What ThemeKit does and does not do

ThemeKit provides:

- an ordered theme-chain model;
- normalization of theme names and fallback chains;
- a request-aware default selector;
- a pluggable custom-selector hook;
- request-local theme state backed by `ContextVar`;
- middleware that activates the selected theme for one request;
- a template loader that tries themed candidates before ordinary templates;
- optional context variables for templates;
- optional response headers for debugging;
- configurable handling of Django's standard error templates;
- Django system checks for common configuration mistakes;
- transparent fallback to ordinary Django templates.

ThemeKit does **not** provide:

- CSS, JavaScript, images, icons, or other static assets;
- a CSS framework or design system;
- a theme registry or database model;
- theme manifests;
- theme installation or download tooling;
- admin screens;
- automatic theme discovery;
- a required directory containing a complete theme;
- assumptions about Bootstrap, Tailwind, Phoenix, AdminLTE, or another UI kit;
- Jinja2 theme resolution;
- automatic mutation of your Django settings.

Your project remains responsible for deciding what a theme means visually and
how its static assets are built and delivered. ThemeKit only affects Django
**template-name resolution**.

---

## Requirements

`django-themekit` currently supports:

- Python 3.11, 3.12, 3.13, and 3.14;
- Django 5.2 and Django 6.0;
- Django's `DjangoTemplates` backend.

The package has no runtime dependency other than Django.

Jinja2 and other rendering backends are outside the current package contract.
If a project configures multiple template backends, ThemeKit affects only the
`DjangoTemplates` backend or backends in which its loader is explicitly
configured.

---

## Installation

Using `uv`:

```bash
uv add django-themekit
```

Using `pip`:

```bash
python -m pip install django-themekit
```

Add ThemeKit's app configuration to `INSTALLED_APPS`:

```python
INSTALLED_APPS = [
    # Django and project applications...
    "themekit.apps.ThemeKitConfig",
]
```

The loader, middleware, and context processor can technically be imported by
Django without installing the app configuration. Installing
`ThemeKitConfig` is nevertheless recommended because its `ready()` hook
registers ThemeKit's Django system checks.

Adding ThemeKit to `INSTALLED_APPS` by itself does not change template
resolution. Template behavior changes only after the ThemeKit loader is added
to the relevant `DjangoTemplates` backend.

---

## Quick start: one configured theme

This is the smallest useful setup for a project with one site-wide theme.
Middleware is not required because the theme does not vary by request.

### 1. Configure the active theme

```python
THEMEKIT_THEME = "phoenix"
```

### 2. Configure the template loader

```python
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
            "context_processors": [
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.contrib.messages.context_processors.messages",
            ],
        },
    },
]
```

`APP_DIRS` must be `False` because the loader list is being configured
explicitly. The app-directories loader is not lost; it is placed **inside**
ThemeKit's wrapper so it receives each themed candidate name.

### 3. Keep the ordinary template

```text
templates/pages/home.html
```

### 4. Add an optional themed override

```text
templates/pages/themes/phoenix/home.html
```

The view remains unchanged:

```python
return render(request, "pages/home.html")
```

When the Phoenix override exists, Django renders it. If it is removed, renamed,
or never created, Django renders `pages/home.html` normally.

---

## Recommended complete configuration

The following configuration supports:

- project-level templates from `TEMPLATES[...]["DIRS"]`;
- templates packaged inside installed Django apps;
- a configured default theme;
- session and user selection;
- request attributes;
- theme context variables;
- optional debugging.

```python
INSTALLED_APPS = [
    # Django applications...
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # Project applications...
    # ThemeKit registers its Django system checks here.
    "themekit.apps.ThemeKitConfig",
]

THEMEKIT_THEME = "phoenix"

MIDDLEWARE = [
    # Existing middleware...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    # ThemeKit must run after the middleware that provides any request state
    # used by the default selector.
    "themekit.middleware.ThemeMiddleware",
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
            "context_processors": [
                "django.template.context_processors.debug",
                "django.template.context_processors.request",
                "django.contrib.auth.context_processors.auth",
                "django.template.context_processors.i18n",
                "django.template.context_processors.media",
                "django.template.context_processors.static",
                "django.template.context_processors.tz",
                "django.contrib.messages.context_processors.messages",
                "themekit.context_processors.theme",
            ],
        },
    },
]
```

Only the pieces a project uses are required:

- The loader enables themed template resolution.
- `THEMEKIT_THEME` supplies a configured theme even without middleware.
- Middleware is required for request-specific selection.
- The context processor is required only when templates need `theme` or
  `theme_chain` variables.
- Debug headers require middleware and `THEMEKIT_DEBUG_HEADER = True`.

---

## Template layout conventions

ThemeKit supports two complementary override layouts.

### Sibling or app-scoped overrides

A themed override can live beside the ordinary template's directory tree:

```text
templates/
└── pages/
    ├── home.html
    └── themes/
        └── phoenix/
            └── home.html
```

The ordinary name:

```text
pages/home.html
```

maps to the Phoenix sibling override:

```text
pages/themes/phoenix/home.html
```

This layout is useful when a theme override conceptually belongs to one app or
one template group.

Nested paths work the same way. The ordinary template:

```text
accounts/profile/detail.html
```

may be overridden by:

```text
accounts/profile/themes/phoenix/detail.html
```

ThemeKit inserts `themes/<theme>/` immediately before the final path
component.

### Global theme trees

A theme may also mirror the entire ordinary template namespace under a global
`themes/<theme>/` directory:

```text
templates/
├── pages/
│   └── home.html
└── themes/
    └── phoenix/
        └── pages/
            └── home.html
```

The ordinary name:

```text
pages/home.html
```

maps to:

```text
themes/phoenix/pages/home.html
```

This layout is useful when a theme is maintained as one coherent tree.

### Both layouts may coexist

A project can use sibling overrides for app-owned templates and global
overrides for shared layouts. ThemeKit defines a deterministic order when both
exist.

---

## Exact template resolution order

For a requested template with at least one slash, ThemeKit resolves candidates
in three phases:

1. sibling overrides for each theme, left to right;
2. global overrides for each theme, left to right;
3. the original template name.

Given:

```python
THEMEKIT_THEME = ["customer", "phoenix"]
```

and:

```text
pages/home.html
```

ThemeKit tries exactly:

```text
1. pages/themes/customer/home.html
2. pages/themes/phoenix/home.html
3. themes/customer/pages/home.html
4. themes/phoenix/pages/home.html
5. pages/home.html
```

Each candidate is passed to the configured wrapped loaders in their configured
order. With the recommended filesystem and app-directories loaders, the search
conceptually becomes:

```text
pages/themes/customer/home.html
    -> filesystem.Loader
    -> app_directories.Loader

pages/themes/phoenix/home.html
    -> filesystem.Loader
    -> app_directories.Loader

themes/customer/pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader

themes/phoenix/pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader

pages/home.html
    -> filesystem.Loader
    -> app_directories.Loader
```

The first successfully loaded template wins.

### Important priority detail

All sibling candidates are tried before any global candidates. Therefore:

```text
pages/themes/phoenix/home.html
```

wins before:

```text
themes/customer/pages/home.html
```

although `customer` appears earlier in the theme chain. Theme priority is
preserved **within each layout class**; sibling layout as a class has priority
over global layout.

This ordering is intentional and is covered by the test suite.

### Explicitly themed template names

If the requested template name already contains a path segment named
`themes`, ThemeKit treats it as explicit and does not rewrite it again.

For example:

```python
render(request, "themes/phoenix/base.html")
```

is loaded exactly as requested. ThemeKit does not construct recursive paths
such as:

```text
themes/phoenix/themes/phoenix/base.html
```

The same rule applies to sibling paths such as:

```text
pages/themes/phoenix/home.html
```

---

## Root templates such as `base.html`

A root template has no directory prefix, so there is no sibling location into
which ThemeKit can insert `themes/<theme>/`.

Given:

```text
base.html
```

and:

```python
THEMEKIT_THEME = ["customer", "phoenix"]
```

ThemeKit tries:

```text
1. themes/customer/base.html
2. themes/phoenix/base.html
3. base.html
```

A typical layout is:

```text
templates/
├── base.html
└── themes/
    ├── customer/
    │   └── base.html
    └── phoenix/
        └── base.html
```

This behavior is especially useful because a project can theme most of its
site by overriding only `base.html` while every page template keeps its
ordinary `{% extends "base.html" %}` statement.

---

## Template inheritance

ThemeKit works with Django template inheritance because `{% extends %}` loads
the named parent through Django's template engine.

An unchanged child template:

```django
{% extends "base.html" %}

{% block content %}
  Home
{% endblock %}
```

can inherit the ordinary base:

```text
templates/base.html
```

or, while Phoenix is active, the themed base:

```text
templates/themes/phoenix/base.html
```

No conditional logic is needed in the child template.

The same resolution engine is applied whenever Django asks the configured
loader for a template name, including templates loaded during normal
inheritance. ThemeKit passes Django's `skip` origin list through to its wrapped
loaders so Django can preserve its normal inheritance and recursion behavior.

A theme can independently override:

- only the parent layout;
- only the child page;
- both the parent and child;
- neither, allowing both ordinary templates to render.

---

## Partial themes and transparent fallback

Themes do not need to be complete.

This is valid:

```text
templates/
├── base.html
├── pages/
│   ├── home.html
│   ├── reports.html
│   └── users.html
└── themes/
    └── phoenix/
        └── base.html
```

Phoenix overrides only `base.html`. All page templates remain ordinary and
inherit the themed base automatically.

This is also valid:

```text
templates/
└── pages/
    ├── home.html
    ├── reports.html
    └── themes/
        └── phoenix/
            └── reports.html
```

Only `pages/reports.html` is themed. `pages/home.html` falls back to its
ordinary file.

Renaming:

```text
pages/themes/phoenix/home.html
```

to:

```text
pages/themes/phoenix/home2.html
```

removes it from the candidate set for `pages/home.html`; the ordinary
`pages/home.html` renders again.

ThemeKit does not scan theme directories, validate completeness, or require a
registry of available themes. A file participates only when its path matches a
candidate generated for the requested template name.

---

## Theme chains

A theme setting or selector may return one theme:

```python
THEMEKIT_THEME = "phoenix"
```

or an ordered fallback chain:

```python
THEMEKIT_THEME = [
    "customer",
    "phoenix",
]
```

Theme values are normalized into immutable tuples internally.

Normalization rules:

- `None` becomes an empty chain;
- an empty or whitespace-only string becomes an empty chain;
- a string becomes a one-item chain;
- a sequence preserves left-to-right order;
- surrounding whitespace is stripped;
- empty items are discarded;
- duplicate names are removed while preserving the first occurrence;
- non-string items in a configured chain raise `TypeError`.

Examples:

```python
"phoenix"
# -> ("phoenix",)
```

```python
[" customer ", "", "phoenix", "customer"]
# -> ("customer", "phoenix")
```

ThemeKit does not currently enforce a slug format. Theme names become path
components, so simple trusted identifiers such as `phoenix`, `customer`, or
`tenant_acme` are strongly recommended.

---

## Theme selection

ThemeKit separates template resolution from theme selection.

The loader asks only:

> What is the active theme chain right now?

The chain may come from:

- the configured `THEMEKIT_THEME` setting;
- request middleware using the built-in selector;
- a project-defined selector;
- request-local state already established by middleware.

This separation allows a static site to use the loader without middleware and
a multi-tenant application to supply its own request policy without replacing
the loader.

---

## Static selection with `THEMEKIT_THEME`

For one site-wide theme:

```python
THEMEKIT_THEME = "phoenix"
```

For a site-specific override with a reusable fallback:

```python
THEMEKIT_THEME = [
    "my_site",
    "phoenix",
]
```

The configured chain is available to the loader even when
`ThemeMiddleware` is not installed.

This makes the following setup valid:

- ThemeKit loader configured;
- `THEMEKIT_THEME` configured;
- no ThemeKit middleware;
- no sessions or authentication required.

Important distinctions:

- `THEMEKIT_THEME` does not install the loader.
- Defining the setting alone does not alter template resolution.
- The loader must be present in `TEMPLATES` for themed overrides to be used.

When `THEMEKIT_THEME` is omitted, `None`, empty, or whitespace-only and no
request-local chain exists, ThemeKit generates only the original template
name. It then behaves as a transparent wrapper around the configured loaders.

---

## Dynamic selection with middleware

Use middleware when the theme can vary by request.

```python
MIDDLEWARE = [
    # Existing middleware...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "themekit.middleware.ThemeMiddleware",
]
```

The default selector uses this priority:

```text
1. request.session[THEMEKIT_SESSION_KEY]
2. getattr(request.user, THEMEKIT_USER_ATTRIBUTE)
3. THEMEKIT_THEME
4. empty chain
```

The defaults are equivalent to:

```python
THEMEKIT_SESSION_KEY = "theme"
THEMEKIT_USER_ATTRIBUTE = "theme"
```

During a request, middleware:

1. selects and normalizes the theme chain;
2. stores it in request-local `ContextVar` state;
3. sets `request.theme` to the first selected theme or `None`;
4. sets `request.theme_chain` to the complete tuple;
5. calls the remainder of Django's request stack;
6. optionally adds debug headers;
7. restores the previous context state in a `finally` block.

State is restored even when the view, template rendering, or later middleware
raises an exception.

### Middleware ordering

Place ThemeKit after any middleware that supplies data used by the selector.
For the built-in policy, that normally means after:

```python
"django.contrib.sessions.middleware.SessionMiddleware"

"django.contrib.auth.middleware.AuthenticationMiddleware"
```

ThemeKit is defensive when `request.session` or `request.user` is absent, but
placing it correctly is necessary for those selection sources to participate.

---

## Session-based selection

With the default session key:

```python
request.session["theme"] = "phoenix"
```

A session may hold a fallback chain:

```python
request.session["theme"] = [
    "preview",
    "phoenix",
]
```

To use another key:

```python
THEMEKIT_SESSION_KEY = "ui_theme"
```

Then:

```python
request.session["ui_theme"] = "phoenix"
```

An empty or invalid session theme is ignored by the built-in selector, which
then tries the user attribute and configured theme.

To stop a session from overriding lower-priority sources:

```python
request.session.pop("theme", None)
```

or use the configured custom key.

Session data should contain trusted theme identifiers selected from an
application-controlled allowlist. Avoid copying arbitrary URL parameters or
unvalidated user input directly into a theme path.

---

## User-based selection

By default, ThemeKit reads:

```python
request.user.theme
```

The attribute may be a model field, property, or other attribute returning:

- a string;
- a sequence of strings;
- `None`.

Example user model field:

```python
class User(AbstractUser):
    preferred_theme = models.CharField(
        max_length=64,
        blank=True,
    )
```

Configure ThemeKit to read it:

```python
THEMEKIT_USER_ATTRIBUTE = "preferred_theme"
```

The default selector then behaves like:

```python
value = getattr(request.user, "preferred_theme", None)
```

If the user attribute is missing, empty, or invalid, selection falls through
to `THEMEKIT_THEME`.

Session selection has higher priority than user selection. This makes a
session useful for temporary previews while a user attribute stores the
persistent preference.

---

## Custom selectors

For tenants, hostnames, organizations, experiments, feature flags, or another
project-specific rule, configure a dotted callable path:

```python
THEMEKIT_SELECTOR = "config.themes.select_theme"
```

Create the selector:

```python
"""
config/themes.py

Project-specific ThemeKit selection policy.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Select tenant branding with Phoenix as the fallback."""
    tenant = request.tenant

    return [
        tenant.theme,
        "phoenix",
    ]
```

The callable receives the current `HttpRequest` and may return:

```python
"phoenix"
```

```python
["tenant_acme", "phoenix"]
```

```python
("preview", "tenant_acme", "phoenix")
```

or `None`/an empty value.

The configured custom selector replaces the built-in session -> user ->
configured-theme selection policy. Therefore, a custom selector should return
the complete chain the project wants for that request.

### Example: hostname selection

```python
"""
config/themes.py

Host-based ThemeKit selection policy.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Select a branded theme from the request hostname."""
    host = request.get_host().partition(":")[0].lower()

    by_host = {
        "customer-a.example.com": "customer_a",
        "customer-b.example.com": "customer_b",
    }

    selected = by_host.get(host)

    if selected is None:
        return ["phoenix"]

    return [selected, "phoenix"]
```

### Example: preview query parameter with validation

```python
"""
config/themes.py

Validated ThemeKit preview selection.
"""

from __future__ import annotations

from django.http import HttpRequest

_ALLOWED_THEMES = {
    "minimal",
    "phoenix",
}


def select_theme(request: HttpRequest) -> list[str]:
    """Allow staff to preview an approved theme."""
    preview = request.GET.get("theme")

    if request.user.is_staff and preview in _ALLOWED_THEMES:
        return [preview, "phoenix"]

    return ["phoenix"]
```

### Empty custom-selector results

A custom selector should normally return an explicit, complete chain. The
current state API distinguishes between the request-local selected chain and
the configured active fallback through separate `current` and `active`
helpers. When an empty current chain is observed by active-resolution code,
`THEMEKIT_THEME` is used as the configured fallback.

For predictable behavior:

- return the complete fallback chain from a custom selector; or
- leave `THEMEKIT_THEME` unset when an empty selector result should mean a
  fully unthemed project.

Do not assume that returning an empty value is an application-wide replacement
for a separately configured `THEMEKIT_THEME`.

Custom selectors are used only by `ThemeMiddleware`. Configuring
`THEMEKIT_SELECTOR` without installing the middleware does not provide a
request object to the loader and therefore does not perform dynamic selection.

---

## Request-local state

ThemeKit stores the current selected chain in a `ContextVar`, not a process
global and not Django settings.

This prevents one request's selected theme from being intentionally stored as
global mutable configuration and allows state to be restored after nested or
failed request processing.

ThemeKit exposes two related concepts:

### Current theme

The current theme is the chain stored in the active execution context.

```python
from themekit import get_current_theme
from themekit import get_current_theme_chain
```

Within ThemeKit middleware:

```python
get_current_theme()
# -> "customer"
```

```python
get_current_theme_chain()
# -> ("customer", "phoenix")
```

Outside request-local state:

```python
get_current_theme()
# -> None
```

```python
get_current_theme_chain()
# -> ()
```

These functions do not independently read the configured theme.

### Active theme

The active theme is the request-local chain when one is available, otherwise
the configured `THEMEKIT_THEME` chain.

```python
from themekit import get_active_theme
from themekit import get_active_theme_chain
```

With no middleware state and:

```python
THEMEKIT_THEME = "phoenix"
```

these return:

```python
get_active_theme()
# -> "phoenix"
```

```python
get_active_theme_chain()
# -> ("phoenix",)
```

The loader and context processor use the active-chain helpers so a static
configured theme works without middleware.

### Request attributes

When middleware is installed, downstream middleware and views may use:

```python
request.theme
```

and:

```python
request.theme_chain
```

For a chain `("customer", "phoenix")`:

```python
request.theme == "customer"
request.theme_chain == ("customer", "phoenix")
```

For no selected request chain:

```python
request.theme is None
request.theme_chain == ()
```

For type annotations, ThemeKit exports `ThemedHttpRequest`:

```python
from themekit import ThemedHttpRequest


def dashboard(request: ThemedHttpRequest) -> HttpResponse:
    return HttpResponse(request.theme or "un-themed")
```

Django itself creates an ordinary `HttpRequest`/`WSGIRequest`; the subclass is
provided as a typing surface for code that runs after `ThemeMiddleware`.

---

## Template context variables

Add ThemeKit's context processor when templates need access to the active
chain:

```python
TEMPLATES = [
    {
        # ...
        "OPTIONS": {
            # ...
            "context_processors": [
                # Existing processors...
                "themekit.context_processors.theme",
            ],
        },
    },
]
```

It exposes:

```text
theme
```

and:

```text
theme_chain
```

For a `("customer", "phoenix")` chain:

```django
{{ theme }}
```

renders:

```text
customer
```

and:

```django
{{ theme_chain|join:"," }}
```

renders:

```text
customer,phoenix
```

Examples:

```django
<body class="theme-{{ theme|default:'none' }}">
```

```django
{% if theme == "phoenix" %}
  <meta name="theme-family" content="phoenix">
{% endif %}
```

```django
{% if "phoenix" in theme_chain %}
  {# Phoenix participates as a fallback theme. #}
{% endif %}
```

Without an active or configured theme:

```python
{
    "theme": None,
    "theme_chain": (),
}
```

The context processor does not choose a request theme. It reads existing
request-local state or the configured fallback. Dynamic session, user, or
custom-selector behavior still requires middleware.

---

## Debug response headers

Enable debug headers:

```python
THEMEKIT_DEBUG_HEADER = True
```

With the chain:

```python
("customer", "phoenix")
```

middleware adds:

```text
X-ThemeKit-Theme: customer
X-ThemeKit-Chain: customer,phoenix
```

Headers are omitted when:

- `THEMEKIT_DEBUG_HEADER` is `False` or omitted;
- middleware is not installed;
- the request's selected chain is empty.

The setting must be a boolean. Debug headers can expose internal branding,
tenant, or preview identifiers, so enable them only where that information is
appropriate to disclose.

---

## Error templates

By default, Django's standard root error template names may be themed:

```text
400.html
403.html
404.html
500.html
```

With Phoenix active, `404.html` resolves as:

```text
1. themes/phoenix/404.html
2. 404.html
```

Disable themed resolution for these exact standard names:

```python
THEMEKIT_DISABLE_ERROR_TEMPLATES = True
```

Then ThemeKit passes only the original name to its wrapped loaders:

```text
404.html
```

This option is useful when a project wants the simplest possible error path or
keeps error pages independent of request branding.

The setting affects only the exact root names listed above. A custom path such
as:

```text
errors/404.html
```

is an ordinary template name and follows normal ThemeKit resolution.

The setting must be a boolean.

---

## Template loader configuration

ThemeKit's loader is a wrapper around the Django loaders your project already
uses.

The wrapper is important because ThemeKit transforms names while Django's
ordinary loaders decide where those names live.

Recommended configuration:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]
```

Do **not** configure this:

```python
"loaders": [
    "themekit.loaders.ThemedLoader",
    "django.template.loaders.app_directories.Loader",
]
```

The direct string form gives `ThemedLoader` no wrapped loader configuration.
The app-directories loader outside the wrapper receives only the original
name, not ThemeKit's themed candidates.

For example, with the incorrect configuration:

```text
ThemedLoader receives pages/home.html
    -> cannot search app templates correctly without wrapped loaders

app_directories.Loader receives pages/home.html
    -> may find the ordinary template
    -> never receives pages/themes/phoenix/home.html
```

All source loaders that should participate in themed resolution must be
inside ThemeKit's wrapper.

---

## Project template directories

Django's filesystem loader searches directories from `TEMPLATES[...]["DIRS"]`.

```python
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [
            BASE_DIR / "templates",
        ],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                    ],
                ),
            ],
        },
    },
]
```

This is sufficient when all templates are reachable through `DIRS`.

A project may list many template roots:

```python
APP_TEMPLATE_DIRS = [
    path for path in (BASE_DIR / "apps").rglob("templates") if path.is_dir()
]

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [
            BASE_DIR / "templates",
            *APP_TEMPLATE_DIRS,
        ],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
        },
    },
]
```

ThemeKit supports this structure. The manual directory list is not required,
however, when standard installed-app templates are covered by the wrapped
app-directories loader.

---

## Installed-app templates

Django's app-directories loader searches the `templates/` directory of each
application in `INSTALLED_APPS`.

Typical app structure:

```text
myapp/
├── apps.py
├── views.py
└── templates/
    └── myapp/
        ├── dashboard.html
        └── themes/
            └── phoenix/
                └── dashboard.html
```

The view renders:

```python
return render(request, "myapp/dashboard.html")
```

The themed sibling candidate is:

```text
myapp/themes/phoenix/dashboard.html
```

For ThemeKit to try that candidate inside installed apps, configure
`app_directories.Loader` **inside** the wrapper:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]
```

The order of `INSTALLED_APPS` remains meaningful for the app-directories
loader, exactly as it is in ordinary Django template resolution.

---

## Custom loaders

ThemeKit can wrap other Django-compatible loaders because its loader
configuration accepts the same string and tuple forms Django uses.

Example with Django's in-memory loader:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            (
                "django.template.loaders.locmem.Loader",
                {
                    "pages/home.html": "ordinary",
                    "pages/themes/phoenix/home.html": "phoenix",
                },
            ),
        ],
    ),
]
```

A project-specific loader may also be wrapped:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "project.templates.DatabaseLoader",
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]
```

For every ThemeKit candidate, wrapped loaders are tried in the listed order.
The custom loader must follow Django's template-loader interface.

---

## Template caching

When `OPTIONS["loaders"]` is explicitly configured, use Django's cached loader
inside ThemeKit when compiled-template caching is desired.

Recommended cached configuration:

```python
TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        (
                            "django.template.loaders.cached.Loader",
                            [
                                "django.template.loaders.filesystem.Loader",
                                "django.template.loaders.app_directories.Loader",
                            ],
                        ),
                    ],
                ),
            ],
        },
    },
]
```

The order matters:

```text
ThemedLoader
└── cached.Loader
    ├── filesystem.Loader
    └── app_directories.Loader
```

ThemeKit first creates a theme-specific candidate such as:

```text
pages/themes/phoenix/home.html
```

The cached loader then uses that candidate name as its cache key.

Do not put `cached.Loader` outside ThemeKit:

```text
cached.Loader
└── ThemedLoader
```

An outer cache sees only the original name, such as `pages/home.html`. A
compiled template selected for one request theme could then be returned for a
later request using another theme. Keeping caching inside ThemeKit gives each
themed candidate its own key.

---

## Performance

ThemeKit adds template-name attempts; it does not add database queries,
network requests, asset compilation, or extra template rendering.

For a template containing a slash and `n` active themes, ThemeKit generates:

```text
2n + 1 candidate names
```

For a root template such as `base.html`, it generates:

```text
n + 1 candidate names
```

Examples:

```text
1 theme, pages/home.html -> at most 3 candidate names
2 themes, pages/home.html -> at most 5 candidate names
2 themes, base.html -> at most 3 candidate names
```

Each candidate is tried against wrapped loaders until a match is found. The
search stops immediately on the first successful load.

Development mode may perform several additional file existence checks on a
cache miss. In production, configure Django's cached loader inside ThemeKit so
compiled templates are cached under theme-specific candidate names.

Selector performance is controlled by the project. The built-in selector only
reads request/session/user/settings state. A custom selector should avoid
unnecessary database or network work, or rely on request state already loaded
by earlier middleware.

---

## Settings reference

### `THEMEKIT_THEME`

Type:

```python
str | Sequence[str] | None
```

Default:

```python
None
```

Purpose:

The configured theme or ordered fallback chain. It is used by the loader even
without middleware and is the final source in the built-in request selector.

Examples:

```python
THEMEKIT_THEME = "phoenix"
```

```python
THEMEKIT_THEME = ["customer", "phoenix"]
```

```python
THEMEKIT_THEME = None
```

An omitted, empty, or whitespace-only value produces no configured chain.

---

### `THEMEKIT_SELECTOR`

Type:

```python
str | None
```

Default:

```python
None
```

Purpose:

A dotted path to a request selector callable. The callable replaces the
built-in session -> user -> configured-theme policy and is invoked by
`ThemeMiddleware`.

Example:

```python
THEMEKIT_SELECTOR = "config.themes.select_theme"
```

The path must import successfully and reference a callable.

---

### `THEMEKIT_SESSION_KEY`

Type:

```python
str
```

Default:

```python
"theme"
```

Purpose:

The session key read by the built-in selector.

Example:

```python
THEMEKIT_SESSION_KEY = "ui_theme"
```

The value must be a non-empty string.

---

### `THEMEKIT_USER_ATTRIBUTE`

Type:

```python
str
```

Default:

```python
"theme"
```

Purpose:

The attribute read from `request.user` by the built-in selector.

Example:

```python
THEMEKIT_USER_ATTRIBUTE = "preferred_theme"
```

The value must be a non-empty string.

---

### `THEMEKIT_DEBUG_HEADER`

Type:

```python
bool
```

Default:

```python
False
```

Purpose:

When middleware selects a non-empty request chain, add:

```text
X-ThemeKit-Theme
X-ThemeKit-Chain
```

The value must be a boolean.

---

### `THEMEKIT_DISABLE_ERROR_TEMPLATES`

Type:

```python
bool
```

Default:

```python
False
```

Purpose:

When `True`, bypass themed candidate generation for exact standard root error
template names:

```text
400.html
403.html
404.html
500.html
```

The value must be a boolean.

---

## Public Python API

ThemeKit intentionally exposes a small package-root API:

```python
from themekit import (
    ThemeMiddleware,
    ThemedHttpRequest,
    get_active_theme,
    get_active_theme_chain,
    get_current_theme,
    get_current_theme_chain,
)
```

### `ThemeMiddleware`

Django middleware that selects and activates a request-local chain, annotates
the request, adds optional debug headers, and restores previous state.

Usually referenced by dotted path in settings:

```python
"themekit.middleware.ThemeMiddleware"
```

### `ThemedHttpRequest`

Typing surface for requests after ThemeKit middleware. It declares:

```python
theme: str | None
theme_chain: tuple[str, ...]
```

### `get_current_theme()`

Returns the first request-local/current theme, or `None`.

### `get_current_theme_chain()`

Returns the request-local/current chain as `tuple[str, ...]`.

### `get_active_theme()`

Returns the first current theme, or the first configured theme when current
state does not supply one.

### `get_active_theme_chain()`

Returns the current chain when available, otherwise the configured
`THEMEKIT_THEME` chain.

Implementation helpers in modules such as `themekit.conf`,
`themekit.selectors`, and `themekit.resolution` are intentionally not exported
from the package root unless they are part of this supported public surface.

---

## Django system checks

Run:

```bash
python manage.py check
```

ThemeKit registers checks through `ThemeKitConfig.ready()`.

Current check identifiers:

### `themekit.E001`

ThemeKit's loader is present while the backend has:

```python
"APP_DIRS": True
```

Explicit loaders require:

```python
"APP_DIRS": False
```

### `themekit.E002`

`ThemedLoader` was configured directly without wrapped loaders:

```python
"themekit.loaders.ThemedLoader"
```

Use tuple configuration:

```python
(
    "themekit.loaders.ThemedLoader",
    [
        "django.template.loaders.filesystem.Loader",
        "django.template.loaders.app_directories.Loader",
    ],
)
```

### `themekit.E003`

A validated ThemeKit setting is invalid, such as:

- an invalid theme chain;
- an invalid selector path or non-callable selector;
- an invalid or empty session key;
- an invalid or empty user attribute name.

### `themekit.W001`

`ThemeMiddleware` is configured but Django's `SessionMiddleware` is absent.

This is a warning because a custom selector may intentionally not use
sessions. With the built-in selector, add `SessionMiddleware` before ThemeKit
if session selection is expected.

System checks require:

```python
"themekit.apps.ThemeKitConfig"
```

in `INSTALLED_APPS` so Django calls the app's `ready()` hook.

---

## Complete configuration recipes

### Recipe 1: static site-wide theme

Use this when every request uses the same configured chain.

```python
INSTALLED_APPS = [
    # ...
    "themekit.apps.ThemeKitConfig",
]

THEMEKIT_THEME = "phoenix"

TEMPLATES = [
    {
        "BACKEND": "django.template.backends.django.DjangoTemplates",
        "DIRS": [BASE_DIR / "templates"],
        "APP_DIRS": False,
        "OPTIONS": {
            "loaders": [
                (
                    "themekit.loaders.ThemedLoader",
                    [
                        "django.template.loaders.filesystem.Loader",
                        "django.template.loaders.app_directories.Loader",
                    ],
                ),
            ],
        },
    },
]
```

No ThemeKit middleware is required.

---

### Recipe 2: session preview over a user preference

```python
THEMEKIT_THEME = "phoenix"
THEMEKIT_SESSION_KEY = "theme"
THEMEKIT_USER_ATTRIBUTE = "preferred_theme"

MIDDLEWARE = [
    # ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "themekit.middleware.ThemeMiddleware",
]
```

Priority:

```text
request.session["theme"]
request.user.preferred_theme
"phoenix"
```

Start a preview:

```python
request.session["theme"] = "minimal"
```

End it:

```python
request.session.pop("theme", None)
```

---

### Recipe 3: tenant theme with framework fallback

```python
THEMEKIT_SELECTOR = "config.themes.select_theme"

MIDDLEWARE = [
    # Tenant middleware must attach request.tenant first.
    "project.tenants.middleware.TenantMiddleware",
    "themekit.middleware.ThemeMiddleware",
]
```

```python
"""
config/themes.py

Tenant-aware ThemeKit selection.
"""

from __future__ import annotations

from django.http import HttpRequest


def select_theme(request: HttpRequest) -> list[str]:
    """Use tenant branding before the shared Phoenix theme."""
    return [
        request.tenant.theme,
        "phoenix",
    ]
```

Directory example:

```text
templates/
├── pages/
│   └── home.html
└── themes/
    ├── tenant_acme/
    │   └── pages/
    │       └── home.html
    └── phoenix/
        ├── base.html
        └── pages/
            └── home.html
```

---

### Recipe 4: project directories only

Use only the filesystem loader when installed-app templates are not needed:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
        ],
    ),
]
```

Every template root must then appear in `DIRS`.

---

### Recipe 5: production caching

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            (
                "django.template.loaders.cached.Loader",
                [
                    "django.template.loaders.filesystem.Loader",
                    "django.template.loaders.app_directories.Loader",
                ],
            ),
        ],
    ),
]
```

Keep `cached.Loader` inside ThemeKit so candidate names remain theme-specific
cache keys.

---

## Testing your integration

ThemeKit is compatible with Django's normal testing tools.

### Test configured-theme rendering

```python
from __future__ import annotations

from django.test import Client, override_settings


@override_settings(THEMEKIT_THEME="phoenix")
def test_home_uses_phoenix_override(client: Client) -> None:
    response = client.get("/")

    assert response.status_code == 200
    assert b"phoenix home" in response.content
```

### Test ordinary fallback

```python
@override_settings(THEMEKIT_THEME="missing_theme")
def test_home_falls_back_to_ordinary_template(client: Client) -> None:
    response = client.get("/")

    assert response.status_code == 200
    assert b"ordinary home" in response.content
```

### Test session selection

```python
def test_session_theme_wins(client: Client) -> None:
    session = client.session
    session["theme"] = "preview"
    session.save()

    response = client.get("/")

    assert response.status_code == 200
    assert b"preview home" in response.content
```

### Test context values

```django
{{ theme|default:"none" }}|{{ theme_chain|join:"," }}
```

```python
@override_settings(THEMEKIT_THEME=["customer", "phoenix"])
def test_context_contains_chain(client: Client) -> None:
    response = client.get("/")

    assert b"customer|customer,phoenix" in response.content
```

### Test inheritance

Ordinary child:

```django
{% extends "base.html" %}
{% block content %}content{% endblock %}
```

Files:

```text
templates/base.html
templates/themes/phoenix/base.html
```

Test:

```python
@override_settings(THEMEKIT_THEME="phoenix")
def test_child_inherits_themed_base(client: Client) -> None:
    response = client.get("/inherited/")

    assert b"phoenix base" in response.content
```

### Run Django checks in CI

```bash
python manage.py check
```

For the package itself:

```bash
make check
make coverage
make release-check
```

---

## Troubleshooting

### The ordinary template renders even though a theme is active

Check all of the following:

1. ThemeKit's loader is configured.
2. `APP_DIRS` is `False`.
3. The relevant source loader is inside ThemeKit's wrapper.
4. The themed path exactly matches the generated candidate.
5. The active chain contains the expected theme.
6. The file is readable and within a configured template source.

For `pages/home.html` and `phoenix`, valid paths are:

```text
pages/themes/phoenix/home.html
themes/phoenix/pages/home.html
```

This is not a matching override:

```text
pages/themes/phoenix/home2.html
```

Enable:

```python
THEMEKIT_DEBUG_HEADER = True
```

to inspect the request-selected theme and chain.

---

### Project-level overrides work, but installed-app overrides do not

The filesystem loader sees `DIRS`; the app-directories loader sees
`<installed app>/templates/` directories.

Ensure both are inside the wrapper:

```python
(
    "themekit.loaders.ThemedLoader",
    [
        "django.template.loaders.filesystem.Loader",
        "django.template.loaders.app_directories.Loader",
    ],
)
```

Putting `app_directories.Loader` after ThemeKit instead of inside it allows the
ordinary app template to load but does not send themed candidate names to that
loader.

---

### Django reports that `APP_DIRS` and `loaders` cannot be used together

Set:

```python
"APP_DIRS": False
```

Then include:

```python
"django.template.loaders.app_directories.Loader"
```

inside ThemeKit's wrapped loader list.

---

### `ThemeMiddleware` does not see the session

Ensure this order:

```python
"django.contrib.sessions.middleware.SessionMiddleware"

"themekit.middleware.ThemeMiddleware"
```

Also verify `THEMEKIT_SESSION_KEY` matches the key written to the session.

---

### `ThemeMiddleware` does not see the user preference

Ensure authentication middleware runs first:

```python
"django.contrib.auth.middleware.AuthenticationMiddleware"

"themekit.middleware.ThemeMiddleware"
```

Verify `THEMEKIT_USER_ATTRIBUTE` matches the field or property name.

---

### `THEMEKIT_SELECTOR` appears to do nothing

A custom selector is request-aware and is called by `ThemeMiddleware`.
Configure both:

```python
THEMEKIT_SELECTOR = "config.themes.select_theme"
```

and:

```python
"themekit.middleware.ThemeMiddleware"
```

Check that middleware required by the selector, such as tenant,
authentication, locale, or session middleware, runs before ThemeKit.

---

### Debug headers are missing

Headers require:

```python
THEMEKIT_DEBUG_HEADER = True
```

plus ThemeKit middleware and a non-empty request-selected chain.

No middleware means no response hook, even though a static configured theme
may still affect the loader.

---

### The wrong theme appears after enabling caching

Verify the cache is inside ThemeKit:

```text
ThemedLoader -> cached.Loader -> source loaders
```

not:

```text
cached.Loader -> ThemedLoader
```

An outer cache may cache the final template under the original unthemed name.

---

### Error pages ignore theme overrides

Check:

```python
THEMEKIT_DISABLE_ERROR_TEMPLATES
```

When `True`, the exact names `400.html`, `403.html`, `404.html`, and
`500.html` use ordinary resolution only.

---

### `theme` and `theme_chain` are missing in templates

Add:

```python
"themekit.context_processors.theme"
```

to the relevant backend's context processors. Render with a request-aware API
such as Django's `render()` or `TemplateResponse` so context processors run.

---

### `python manage.py check` does not report ThemeKit checks

Ensure:

```python
"themekit.apps.ThemeKitConfig"
```

is in `INSTALLED_APPS`.

---

## Migrating an existing local theme implementation

A project with local settings such as:

```python
DEFAULT_SITE_THEME = "phoenix"
THEMES_DISABLE_FOR_ERROR_TEMPLATES = False
```

can migrate to:

```python
THEMEKIT_THEME = "phoenix"
THEMEKIT_DISABLE_ERROR_TEMPLATES = False
```

Replace local middleware:

```python
"atlas.core.themes.middleware.ThemeMiddleware"
```

with:

```python
"themekit.middleware.ThemeMiddleware"
```

Replace the local context processor:

```python
"atlas.core.themes.context_processors.theme"
```

with:

```python
"themekit.context_processors.theme"
```

Replace a local filesystem-only loader configuration such as:

```python
"loaders": [
    "atlas.core.themes.loaders.ThemedLoader",
    "django.template.loaders.app_directories.Loader",
]
```

with the general wrapper:

```python
"loaders": [
    (
        "themekit.loaders.ThemedLoader",
        [
            "django.template.loaders.filesystem.Loader",
            "django.template.loaders.app_directories.Loader",
        ],
    ),
]
```

The wrapper configuration handles both:

- projects that manually place every app template root in `DIRS`;
- conventional Django projects that rely on installed-app template discovery.

Existing paths remain valid:

```text
pages/themes/phoenix/home.html
themes/phoenix/pages/home.html
themes/phoenix/base.html
```

Review template context variable names. ThemeKit exposes:

```text
theme
theme_chain
```

rather than legacy names such as:

```text
current_theme
current_theme_chain
```

Review legacy hard-coded fallback behavior as well. ThemeKit does not invent a
built-in theme named `default`. When no source selects a theme, the active
chain is empty and ordinary templates resolve normally.

Finally, run:

```bash
python manage.py check
```

and the project's full test suite.

---

## Internal architecture

ThemeKit is intentionally split into small modules with one responsibility
each.

### `themekit.conf`

- defines ThemeKit setting names;
- normalizes theme values into ordered tuples;
- validates boolean and string settings;
- imports a configured selector callable;
- exposes configured theme and selection settings.

### `themekit.state`

- stores request-local theme state in a `ContextVar`;
- distinguishes current and active theme access;
- supports token-based restoration for nested or failed execution.

### `themekit.selectors`

- implements the built-in session -> user -> configured-theme policy;
- tolerates missing or partially initialized request state;
- invokes a configured custom selector;
- normalizes selector results.

### `themekit.middleware`

- activates the selected chain for one request;
- adds `request.theme` and `request.theme_chain`;
- adds optional debug headers;
- restores previous context state in `finally`.

### `themekit.resolution`

- contains the pure template-name algorithm;
- generates sibling candidates;
- generates global candidates;
- preserves ordinary fallback;
- prevents recursive rewriting of explicitly themed names.

### `themekit.loaders`

- wraps arbitrary Django template loaders;
- asks each loader to resolve each generated candidate;
- preserves wrapped-loader ordering;
- forwards Django's inheritance `skip` origins;
- chains `TemplateDoesNotExist` failures.

### `themekit.context_processors`

- exposes `theme` and `theme_chain` to Django templates.

### `themekit.checks`

- validates common settings and loader mistakes through Django's checks
  framework.

### `themekit.apps`

- registers ThemeKit under Django;
- imports system checks during app readiness.

### `themekit.__init__`

- defines the intentionally small public package-root API.

This separation is deliberate:

```text
settings
   -> conf

request
   -> selectors
   -> middleware
   -> state

requested template name + active state
   -> resolution
   -> loader
   -> wrapped Django loaders

active state
   -> context processor
   -> template variables
```

---

## Security and trust boundaries

Theme names are used as template path components. ThemeKit strips whitespace
and validates chain item types, but it does not currently enforce a slug
pattern or maintain an allowlist.

Applications should:

- use simple theme identifiers;
- validate preview parameters;
- map tenants or users to approved theme names;
- avoid accepting arbitrary untrusted path fragments;
- avoid exposing debug headers when theme identifiers reveal sensitive tenant
  or experiment details.

Django's built-in filesystem loaders protect configured template roots, but an
application should still treat theme selection as controlled configuration,
not as a general-purpose user-supplied file path.

A custom selector executes during request handling. It should be deterministic,
fast, and free of unsafe side effects.

---

## Non-goals

ThemeKit intentionally does not attempt to become a complete frontend or theme
marketplace framework.

The following remain project responsibilities:

- theme CSS and JavaScript;
- static-file names and storage;
- build pipelines;
- asset manifests;
- design tokens;
- user-facing theme-switcher views and forms;
- database persistence of theme preferences;
- tenant models;
- permissions around theme previews;
- theme completeness auditing;
- visual documentation.

Keeping those concerns outside ThemeKit allows the package to remain useful
across very different Django stacks.

---

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the local
development workflow, quality checks, testing requirements, and pull-request
guidelines.

Project changes are recorded in [CHANGELOG.md](CHANGELOG.md).

Useful local commands include:

```bash
make sync
make format
make check
make coverage
make release-check
```

ThemeKit maintains complete statement and branch coverage for its supported
behavior.

---

## License

`django-themekit` is released under the MIT License. See
[LICENSE](LICENSE) for the full text.

---

## Django references

ThemeKit builds on Django's documented template extension points:

- [Django template API and engine configuration](https://docs.djangoproject.com/en/6.0/ref/templates/api/)
- [Django template loader types](https://docs.djangoproject.com/en/6.0/ref/templates/api/#loader-types)
- [Writing custom Django template loaders](https://docs.djangoproject.com/en/6.0/ref/templates/api/#custom-loaders)
- [Django context processors](https://docs.djangoproject.com/en/6.0/ref/templates/api/#writing-your-own-context-processors)
- [Django system check framework](https://docs.djangoproject.com/en/6.0/topics/checks/)
