# mdjango docs

> A drop-in Django app that renders a tree of markdown into a documentation site.

---

# mdjango

mdjango is a Django app that allows you to quickly setup a decent technical documentation site. You install it, point
it at a directory of markdown files, the **Content tree**, and include its URLs under a prefix. Your
running Django process then serves a documentation site at that prefix: header, section navigation,
article, table of contents, prev/next links, full-text search, dark mode, and the `llms.txt` family
of machine-readable artifacts. The docs sit behind the same middleware, authentication and
deployment as the rest of the project. You can use it stand-alone or as part of a larger Django app.
There's also an option to generate the docs as a static site that you can host as plain html files
outside the Django process.

There is one fixed **House style**. You set the ends of two colour Ramps, an accent, a font and a
base size. The layout and the rest of the palette are derived and locked. This is by design.
mdjango is meant to be a drop-in app that you can setup in 30 mins. Not something you should be
tweaking and customizing endlessly. If you need more customizing, you're better of building your
own docs feature in your current Django app, or using another off-the-shelf tool that allows for
more customization.

This site is mdjango documenting itself: a Django project that mounts mdjango with no overrides, so
what you are looking at is the default. Every page has a `.md` alternate, linked from the header.

## Read in this order

1. [Install and mount](getting-started/install-and-mount/) takes an existing project to a rendered
   page.
2. [Write a page](how-to/write-a-page/) and [Structure a content tree](how-to/structure-a-content-tree/)
   cover authoring.
3. [Brand the header](how-to/brand-the-header/) and [Change the colours and type](how-to/change-colours-and-type/)
   make it yours.
4. [Run in production](how-to/run-in-production/) before you deploy. Caching and draft visibility
   change when `DEBUG` is off.

The [reference](reference/settings/) section lists every setting, route, front-matter key and CSS
token. The [explanation](explanation/the-filesystem-is-the-source-of-truth/) section covers the
design choices you will meet when operating it.

If all you need is the docs and nothing else from Django, [Export a static site](how-to/export-a-static-site/)
writes the same site to a directory.

---

# Install and mount

This tutorial takes an existing Django project from nothing to a rendered documentation page served
by your own `runserver`. It follows one path and skips every option. The how-to pages cover those.

**You need:** a Django 5.2+ project on Python 3.11+ with the default `startproject` settings:
`django.contrib.staticfiles` in `INSTALLED_APPS` and `APP_DIRS: True` in `TEMPLATES`. mdjango adds
no database tables, so there are no migrations to run.

## 1. Install the package

```bash
pip install mdjango
```

The package depends on Django, `Markdown`, `pymdown-extensions`, `Pygments` and `django-cotton`.

## 2. Register the apps

```python
# settings.py
INSTALLED_APPS = [
    # ...
    "django.contrib.staticfiles",
    "django_cotton",
    "mdjango",
]
```

Both apps are required. mdjango's templates are cotton components, and listing `django_cotton`
installs the template loader that compiles them.

## 3. Point mdjango at a content directory

```python
# settings.py
MDJANGO_CONTENT_DIR = BASE_DIR / "content"
MDJANGO_BRAND = "acme"
```

`MDJANGO_CONTENT_DIR` is the only required setting. `MDJANGO_BRAND` is the wordmark in the header.
Without it the site calls itself "docs".

## 4. Include the URLs

```python
# urls.py
from django.urls import include, path

urlpatterns = [
    # ...
    path("docs/", include("mdjango.urls")),
]
```

To serve the docs at the site root instead (when the whole site is documentation), mount at `""` —
see [Serve the docs at the site root](../../reference/urls/#serve-the-docs-at-the-site-root).

## 5. Write two pages

```bash
mkdir -p content/guides
```

````markdown
<!-- content/_index.md -->
---
title: Acme docs
---

# Acme docs

Welcome. Start with the [first guide](guides/first-guide/).
````

````markdown
<!-- content/guides/first-guide.md -->
---
title: First guide
---

# First guide

## Install

Run the installer.

```bash
acme install
```

## Verify

Run `acme --version`.
````

## 6. Run the server and open the site

```bash
python manage.py runserver
```

Open <http://127.0.0.1:8000/docs/>. You see your Index page with **acme** as the wordmark, a left
navigation holding a pinned *Acme docs* link and a *Guides* section, and the link to your first
guide. Click it. The page renders with an "On this page" table of contents built from the two `##`
headings, a copy button on the code block, and prev/next links at the bottom. Press `/` to open
search and type `installer`. The guide is found by its body text.

Edit `first-guide.md` and reload. With `DEBUG = True` the content is re-read on every request, so
the change is already there.

## Where next

- [Structure a content tree](../../how-to/structure-a-content-tree/) to add Sections, order them and
  group Pages.
- [Brand the header](../../how-to/brand-the-header/) to add a version chip, a GitHub link and a
  proper `<title>`.
- [Run in production](../../how-to/run-in-production/) before you deploy. `DEBUG = False` changes
  how content is read and cached.

---

# Write a page

**Goal:** add one markdown file that renders with the right title, a table of contents, highlighted
code and working links.

**You need:** a Content tree that already serves. The full key list and parser rules are in the
[content tree reference](../../reference/content-tree/); the extension list is in the
[markdown reference](../../reference/markdown/).

## Start with front-matter and an H1

````markdown
---
title: Roll back a deploy
weight: 30
description: Return a host to the previous release.
---

# Roll back a deploy
````

Front-matter is a block of `key: value` lines between two lines that are exactly `---`. It is not
YAML: no lists, no nesting, no multi-line values. Five keys are read.

| Key | Effect |
|---|---|
| `title` | navigation label and `<title>`. Falls back to the first `#` heading, then the filename. |
| `weight` | position among siblings, lowest first. Default `100`. |
| `draft` | `true` hides the page unless drafts are included. |
| `description` | the suffix of the page's `llms.txt` entry. |
| `updated` | an ISO date (`2024-03-15`) for the page's sitemap `<lastmod>`. Optional; a typo is ignored with a build warning. |

Put an H1 in the body too. The article title is the body's `#` heading, and the `.md` alternate of a
page without one gets a synthesised heading.

## Use H2 and H3 for the table of contents

```markdown
## Stop traffic

### Drain the pool

## Restore the previous build
```

The "On this page" rail lists H2 and H3 headings only. H4 gets an anchor and a permalink but is not
listed. Every heading from H2 to H4 gets a `#` permalink.

## Tag every code fence

````markdown
```bash
acme rollback web
```
````

Untagged fences render as plain preformatted text. The highlighter does not guess a language. Every
fence gets a copy button.

To show a fence inside a fence, make the outer fence four backticks. A three-backtick outer fence is
closed by the inner one and the rest of the example leaks out as headings and paragraphs.

## Use a blockquote for a callout

```markdown
> Rolling back does not restore the database. See the restore guide.
```

Admonitions (`!!! note`) are not enabled and render as a paragraph. Tables, definition lists,
`~~strikethrough~~` and `~subscript~` are enabled.

## Link to another page

Pages are served one level deeper than they are stored: `how-to/deploy.md` is served at
`/docs/how-to/deploy/`. Relative links start from the page's URL directory, not its file's
directory. End every link with a trailing slash.

| From a page in… | To a sibling | To a page in another Section |
|---|---|---|
| a Section (`how-to/deploy.md`) | `](../rollback/)` | `](../../reference/cli/)` |
| a Subsection (`how-to/hosts/harden.md`) | `](../provision/)` | `](../../../reference/cli/)` |
| the root `_index.md` | — | `](how-to/deploy/)` |

Writing `](../how-to/rollback/)` from `how-to/deploy.md` doubles the segment and 404s. Nothing
rewrites links and there is no redirect table. Moving a page breaks every link aimed at it until you
update them.

## Add an image

Put the image **beside the page** in the Content tree and reference it with a path relative to the
page. mdjango serves it and rewrites the link to its URL — at runtime and in the static export:

```markdown
![A deploy pipeline](diagram.png)
```

The reference is relative to the page's own directory, so `images/diagram.png` and
`../shared/logo.svg` work too. Images out of the box means `png`, `jpg`/`jpeg`, `gif`, `svg`, `webp`
— widen or narrow that with [`MDJANGO_ASSET_EXTENSIONS`](../../reference/settings/). Any other file
type (and any `.md`) is left alone: it is not served, and a reference to it is emitted unchanged.

To make an image **click to enlarge**, link it to itself — a plain link, no JavaScript:

```markdown
[![A deploy pipeline](diagram.png)](diagram.png)
```

Markdown is not a Django template: `{% static %}` is not evaluated in a page, and an *absolute* URL
(`/static/…` or `https://…`) is always emitted as written, so a shared asset your project already
serves still works.

## Check the result

```bash
python manage.py mdjango_build --check
```

This renders every page without writing and exits non-zero on a broken tree. See
[Validate content in CI](../validate-content-in-ci/).

---

# Structure a content tree

**Goal:** shape the left navigation: which page is home, what order things appear in, which Pages
collapse together, and what stays hidden.

**You need:** a Content tree that already serves. The rules this page applies are listed in the
[content tree reference](../../reference/content-tree/).

## Set the Index page

```markdown
<!-- content/_index.md -->
---
title: Acme docs
---

# Acme docs

Start with [installation](getting-started/install/).
```

A root `_index.md` is the **Index page**: it is served at the mount root and appears as the pinned
**Home link** above every Section in the navigation, labelled with its title. Without it, the mount
root serves the first Page in navigation order and there is no Home link.

The root `_index.md` ignores `draft`. It is read whether or not drafts are included.

## Order Sections and Pages

```markdown
<!-- content/getting-started/_index.md -->
---
title: Getting started
weight: 10
---
```

A Section's `_index.md` supplies only its `title` and `weight`. Its body is discarded. Without one,
the Section is titled from its directory name and sorts at weight `100`.

Everything sorts by `(weight, title)` among its siblings, lowest weight first, whatever its kind.
Give explicit weights to anything whose order matters and leave the rest at the default. A Loose
page at the content root sorts among the Sections by the same rule.

## Group Pages into a Subsection

Reach for a Subsection when a Section's page list has stopped being scannable, not by default. A
Section with five Pages is better flat.

```bash
mkdir content/how-to/hosts
git mv content/how-to/provision.md content/how-to/harden.md content/how-to/hosts/
```

```markdown
<!-- content/how-to/hosts/_index.md -->
---
title: Hosts
weight: 40
---
```

A Subsection renders collapsed in the navigation, expanded only while the reader is on one of its
Pages.
It has no URL of its own. It sorts among the Section's Pages by its weight, so it can sit in the
middle of a sequence.

Moving a Page into a Subsection changes its URL from `/docs/how-to/harden/` to
`/docs/how-to/hosts/harden/`. Every relative link aimed at it, and every link from it, shifts by one
segment. Rewrite the links in the same commit. See the link table in
[Write a page](../write-a-page/).

A directory inside a Subsection is an error. There is no fourth level.

## Hide work in progress

```markdown
---
title: Multi-region failover
draft: true
---
```

A draft is omitted from navigation, search, the LLM artifacts and the static export. Drafts are
included when `MDJANGO_INCLUDE_DRAFTS` is true, which defaults to `DEBUG`, so you see them under
`runserver` and not in production.

A Section or Subsection whose Pages are all drafts disappears with them. This is not an error.

## Check the result

```bash
python manage.py mdjango_build --check
```

A nested directory inside a Subsection or two files resolving to the same path fail here with a
message naming the files. In a running site the same fault raises on the first request that reads
the tree and returns a 500. See [Validate content in CI](../validate-content-in-ci/).

---

# Brand the header

**Goal:** make the header say your project's name and link to your places.

**You need:** write access to the project's `settings.py`. Defaults for every setting are in the
[settings reference](../../reference/settings/).

## Set the text and links

```python
# settings.py
MDJANGO_BRAND = "acme"                               # wordmark, left of the header
MDJANGO_SITE_TITLE = "Acme documentation"            # <title>; falls back to MDJANGO_BRAND
MDJANGO_HOME_URL = "/"                               # where the wordmark links
MDJANGO_VERSION = "v2.3.0"                           # display-only chip; empty hides it
MDJANGO_GITHUB_URL = "https://github.com/acme/acme"  # link labelled "github"; empty hides it
MDJANGO_HEADER_LINKS = [
    {"label": "changelog", "url": "/changelog/"},
    {"label": "status", "url": "https://status.acme.example"},
]
```

`MDJANGO_HEADER_LINKS` also accepts `mdjango.conf.HeaderLink(label, url)` instances. A dict missing
either key raises a `KeyError` on the first request.

The header renders left to right: hamburger (narrow screens only), wordmark, breadcrumb, search
trigger, version chip, the two `llms.txt` links, your header links in order, the GitHub link, the
theme toggle. Below 768px the breadcrumb and the `llms.txt` links hide and the navigation collapses
into a drawer.

`MDJANGO_VERSION` is a display string. It does not select a version of the content.

## What is not configurable

The wordmark is text. There is no logo setting, and the header's layout, order and labels are part
of the House style. The colours and typeface it uses come from the seeds in
[Change the colours and type](../change-colours-and-type/).

## Check the result

Reload any docs page. Under `DEBUG` settings are read on every request, so no restart is needed. In
production, restart the workers: the rendered page is cached for `MDJANGO_CACHE_SECONDS`, and a
shared cache backend keeps the old header until that expires. See
[Run in production](../run-in-production/).

---

# Change the colours and type

**Goal:** give the docs your palette, typeface and base size without touching the layout.

**You need:** a static directory your project serves. The token names and shipped defaults are in
the [theme tokens reference](../../reference/theme-tokens/). For six worked examples of what the
seeds express, light and dark, see the [theme gallery](../../explanation/theme-gallery/).

## 1. Point mdjango at your stylesheet

Set `MDJANGO_EXTRA_CSS` in your Django settings to a static path — a string, or a list for several.
mdjango loads each one with a `<link>` after its own sheet, so your rules win without touching any
template:

```python
# settings.py
MDJANGO_EXTRA_CSS = "acme/docs-theme.css"
```

Each value is a `{% static %}` name, resolved through your static files the same way mdjango's own
sheet is, so place the file under a static directory your project serves (e.g.
`acme/static/acme/docs-theme.css`). External/CDN URLs are not supported here — the file must be on a
static path. mdjango's sheet is one flat file with no `@layer`, so a later rule of equal specificity
wins.

## 2. Set the seeds

Create the file you named (`acme/docs-theme.css`) and set your seeds:

```css
:root {
  /* surface ramp: page ground -> hairline */
  --background: #ffffff;
  --border: #e2e6ec;

  /* text ramp: full emphasis -> lowest emphasis */
  --foreground: #14181f;
  --foreground-subtle: #7b8494;

  --accent: #2f6df6;                        /* article links; omit to stay monochrome */
  --font: "Inter", system-ui, sans-serif;   /* body and chrome; code stays monospace */
  --font-size: 15px;                        /* every other size is a fraction of this */
}
```

These are the seven Seed values. Set any subset. The three stops between the ends of each Ramp
(`--surface`, `--foreground-body`, `--foreground-muted`) are Derived values, recomputed from your
ends and locked. Setting them directly is not supported.

The values above are an example, not the shipped defaults. The defaults are the warm palette and
IBM Plex Mono at `14px`.

## 3. Set the dark values twice

Dark mode is applied by two rules of equal specificity: one for a reader who pressed the toggle,
one for a reader whose system is dark and who never touched it. Override both, or the two groups
see different palettes.

```css
:root[data-theme="dark"] {
  --background: #0f1216;
  --border: #262c35;
  --foreground: #e8ebf0;
  --foreground-subtle: #7e8794;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #0f1216;
    --border: #262c35;
    --foreground: #e8ebf0;
    --foreground-subtle: #7e8794;
  }
}
```

The toggle stores its choice in `localStorage` under `mdjango-theme`. Clear that key to return to
following the system.

## 4. Check both themes

Reload a page, press the theme toggle, and read a paragraph, a muted nav label and a hairline in
each mode. The interior stops carry whatever chroma your ends carry, so a warm or tinted palette
survives, but nothing checks contrast for you. A `--border` very close to `--background` gives faint
hairlines and a faint `--surface`. A `--foreground-subtle` close to `--background` makes muted text
unreadable.

## Use the font on your own pages

The `@font-face` rules for IBM Plex Mono ship separately for templates mdjango does not render:

```django
<link rel="stylesheet" href="{% static 'mdjango/fonts.css' %}">
```

## If you also export a static site

`mdjango_build` resolves each `MDJANGO_EXTRA_CSS` name through the staticfiles finders and copies
the file into the export, so a themed build is self-contained — no extra step. If a name can't be
resolved the build prints a warning and leaves it out; check the file is on a static path the
finders search (an app's `static/` directory or a `STATICFILES_DIRS` entry). See
[Export a static site](../export-a-static-site/).

---

# Add analytics and other `<head>` tags

**Goal:** get a tracking script, a site-verification tag or a preconnect into every page's `<head>`
without changing anything else about the shell.

**You need:** a `templates/` directory your Django project loads ahead of mdjango. This is the one
template mdjango invites you to override; the rest of the [templates reference](../../reference/templates/)
is a closed surface. Colours and type do **not** go here — they load through a setting, covered in
[Change the colours and type](../change-colours-and-type/).

## 1. Shadow the head slot

mdjango renders an empty component, `cotton/docs/head_extra.html`, at the end of every page's
`<head>`. Override it by creating a file at the same path inside a templates directory your project
loads before mdjango:

```text
your_project/
  templates/
    cotton/
      docs/
        head_extra.html   # your version wins over mdjango's empty one
```

For Django to find yours first, that directory must be ahead of mdjango on the loader path — either
an entry in `TEMPLATES[0]["DIRS"]` (searched before any app), or an app listed before `mdjango` in
`INSTALLED_APPS` with `APP_DIRS` on.

## 2. Put your tags in it

Whatever you write renders verbatim inside `<head>`. A privacy-friendly analytics one-liner:

```django
<script defer data-domain="docs.example.com" src="https://plausible.io/js/script.js"></script>
```

Google Analytics 4 is two tags — an external loader and an inline bootstrap; both go in the slot
together:

```django
<script async src="https://www.googletagmanager.com/gtag/js?id=G-XXXXXXX"></script>
<script>
  window.dataLayer = window.dataLayer || [];
  function gtag() { dataLayer.push(arguments); }
  gtag("js", new Date());
  gtag("config", "G-XXXXXXX");
</script>
```

The slot is not only for analytics. A verification `<meta>` or a `preconnect` works the same way:

```django
<meta name="google-site-verification" content="…">
<link rel="preconnect" href="https://plausible.io">
```

## 3. Keep CSS in the setting, not the slot

The two override channels do not overlap — use one each:

- **Colours, type, the seven CSS seeds** → `MDJANGO_EXTRA_CSS`, a setting (see
  [Change the colours and type](../change-colours-and-type/)).
- **Scripts and `<head>` tags** → the head slot.

`head_extra.html` is the *only* template mdjango supports overriding. Shadowing any other component —
the header, the base document, the page — is unsupported: a project that needs different chrome (a
logo, a footer, a different layout) has outgrown mdjango.

## 4. The export picks it up too

If you [export a static site](../export-a-static-site/), `mdjango_build` renders through your
project's own template loaders, so your `head_extra.html` lands in every exported page with no extra
step. One constraint: the export does not rewrite URLs, so tags in the slot must use **absolute**
URLs — as third-party snippets already do. A relative `src` would resolve against the page's own deep
path and 404.

## What the slot cannot do for you

- **A third-party script runs only where its host is reachable.** Opened from disk (a `file://`
  export with no server) or under a Content-Security-Policy that blocks the host, it silently does
  nothing and the page still renders. This is the trade-off for a tag mdjango does not ship — the
  external dependency is yours to accept. If your site sets a strict CSP, add the analytics host to
  it, the way the [production guide](../run-in-production/) notes you must already allow mdjango's own
  inline theme script.
- **The slot is head-only.** It cannot add a footer, change the header, or alter the layout. That is
  by design: it renders inside `<head>`, so it reaches nothing else.

---

# Publish the LLM artifacts

**Goal:** make the machine-readable views of the docs useful to an agent, or turn them off.

**You need:** a served Content tree. The artifacts are on by default. Their exact format and routes
are in the [URLs reference](../../reference/urls/).

## Know what is already published

| URL | Content |
|---|---|
| `/docs/llms.txt` | site title, optional summary, one linked entry per page grouped by Section and Subsection |
| `/docs/llms-full.txt` | every page's markdown in one document, in navigation order |
| `/docs/<page>.md` | one page's markdown. Also `/docs/index.md` for the Index page |

All three are source markdown, not rendered HTML, and are served with
`text/markdown; charset=utf-8`. Every HTML page carries a `<link rel="alternate" type="text/markdown">`
pointing at its own `.md`.

## Describe the site

```python
# settings.py
MDJANGO_SITE_TITLE = "Acme documentation"
MDJANGO_DESCRIPTION = "Install, configure and operate the Acme CLI and its hosted control plane."
```

The title becomes the `#` heading of both site-wide artifacts. The description becomes a `>`
blockquote under it. Without a title the heading falls back to `MDJANGO_BRAND`, then to
"Documentation".

## Describe each page

```markdown
---
title: Roll back a deploy
description: Return a host to the previous release without touching the database.
---
```

The `description` key becomes the suffix of the page's `llms.txt` entry:

```text
- [Roll back a deploy](/docs/how-to/rollback.md): Return a host to the previous release without touching the database.
```

Write one sentence an agent can select on. A page without a description is listed without a suffix.

Inside a Section, `llms.txt` lists the Section's own Pages first and then each Subsection under a
`###` heading, even where the navigation interleaves them by weight. Markdown headings cannot
express interleaving without misfiling a page.

## Serve llms.txt at the domain root

The convention expects `/llms.txt` at the root. mdjango serves it under the mount. Redirect:

```python
# urls.py
from django.urls import include, path
from django.views.generic import RedirectView

urlpatterns = [
    path("llms.txt", RedirectView.as_view(pattern_name="mdjango:llms_txt")),
    path("docs/", include("mdjango.urls")),
]
```

## Switch the surface off

```python
# settings.py
MDJANGO_LLM_DOCS = False
```

The four markdown routes (`llms.txt`, `llms-full.txt`, `index.md`, `<page>.md`) return 404, the
header and drawer links disappear, and the `<link rel="alternate">` is dropped. `search-index.json`
is unaffected. Search is not part of this surface and cannot be disabled.

---

# Validate content in CI

**Goal:** catch a malformed Content tree, a page that fails to render, or a broken search index at
build time instead of as a 500 on the first request.

**You need:** a CI job that can install the project and import its Django settings. The command's
full behaviour is in the [`mdjango_build` reference](../../reference/mdjango-build/).

## Run the check

```bash
python manage.py mdjango_build --check
```

```text
ok — N pages render, content valid
```

The command clears mdjango's in-process caches, reads the Content tree, renders every page through
the same template the runtime views use, and builds the search index and the LLM artifacts. It
writes nothing to disk.

On a render failure it prints one `<url>: <error>` line per page to stderr, then exits with
status 1:

```text
CommandError: N page(s) failed to render
```

A structural fault in the tree fails before any page renders, with the message naming the files:

```text
CommandError: content is capped at three levels (section -> subsection -> page); found a nested directory content/how-to/hosts/cloud inside subsection 'hosts'
```

```text
CommandError: duplicate page path 'how-to/deploy': content/how-to/deploy.md and content/how-to/Deploy.md
```

## Run it with production settings

Drafts are included when `MDJANGO_INCLUDE_DRAFTS` is true, which defaults to `DEBUG`. Run the check
with the settings you deploy so it validates exactly the pages production will serve:

```bash
DJANGO_SETTINGS_MODULE=config.settings_production python manage.py mdjango_build --check
```

The check needs no database, no secret beyond what your settings module insists on, and no network.

## Why this is the only gate

In a running site the Content tree is read on the first request after a restart. A fault there
raises `ContentError`, which no view catches, so the request returns a 500 and every request after
it does too until the tree is fixed. There is no startup check and no system check. Run the command
on every change to the Content tree and before every deploy.

---

# Run in production

**Goal:** serve the docs from your deployed Django process, alongside everything else it does, with
caching that matches who may read them.

**You need:** a project that serves mdjango under `runserver`. Setting defaults are in the
[settings reference](../../reference/settings/). The three cache layers are explained in
[How caching works](../../explanation/how-caching-works/).

## Serve the static assets

The Shell needs mdjango's stylesheet, fonts and controllers from your `STATIC_URL`. Django does not
serve static files with `DEBUG = False`. Collect them and serve them the way you serve the rest of
the project's static, for example with WhiteNoise:

```bash
python manage.py collectstatic --noinput
```

Nothing is fetched from a CDN. If your CSP blocks inline scripts, allow the short inline theme
script in `<head>`; without it dark mode flashes on load.

## Know what DEBUG turns off

Two settings default to `DEBUG`:

| Setting | Under `DEBUG = True` | Under `DEBUG = False` |
|---|---|---|
| `MDJANGO_INCLUDE_DRAFTS` | drafts are served | drafts are hidden |
| `MDJANGO_ALWAYS_REBUILD` | the tree is re-read on every request and nothing is cached | the tree is read once per process and pages are cached |

Set either explicitly if you need the other behaviour in an environment.

## Set the cache for public docs

```python
# settings.py
MDJANGO_CACHE_SECONDS = 300   # the default
```

One setting drives two things: the rendered HTML is stored in Django's default cache for that many
seconds, and every response carries `Cache-Control: public, max-age=300`. Browsers and any CDN in
front of you may cache the page and serve it without touching Django. Every response also carries a
strong `ETag`, and a matching `If-None-Match` gets a `304`.

Raise the number for docs that change rarely behind a CDN. `0` turns the server-side cache off and
sends `Cache-Control: no-cache` instead.

## Restrict the docs to signed-in users

mdjango's views are plain class-based views with no authentication of their own. They sit behind
whatever middleware the project has. To require login, use Django's middleware and set the cache to
zero:

```python
# settings.py
MIDDLEWARE = [
    # ...
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.auth.middleware.LoginRequiredMiddleware",
]

MDJANGO_CACHE_SECONDS = 0
```

The second line is not optional. `Cache-Control: public` tells a shared cache it may store the
response and serve it to the next visitor, whoever they are. With the setting at `0` the header is
`no-cache`. The `ETag` is still emitted, so a browser that already holds a page still gets `304`s.

The search index, the `llms.txt` files and the `.md` alternates are routes under the same mount, so
the middleware gates them too. The search palette fetches its index with the visitor's session
cookie.

## Ship a content change

With `DEBUG = False` the Content tree is read once per worker process and held for the life of that
process. A content change is a deploy: restart the workers.

If `CACHES["default"]` is a shared backend such as Redis or Memcached, a restart does not clear it.
Rendered pages keep serving from it for up to `MDJANGO_CACHE_SECONDS` after the deploy, and so does
a page whose header or settings you changed, because the cache key is the page path alone. Either
accept the delay, clear that cache as a deploy step, or set the number low enough not to matter.
With the default in-process cache a restart clears everything.

Run `mdjango_build --check` before the deploy. A broken tree is a 500 on the first request, not a
startup error. See [Validate content in CI](../validate-content-in-ci/).

## Mount constraints

Mount with `path("<prefix>/", include("mdjango.urls"))` and nothing else. The URLconf sets its own
`mdjango` namespace, and the templates and services reverse routes by that name, so passing a
different `namespace=` to `include()` or mounting the URLconf twice breaks every internal link.

## If you only need the docs

A Django process is the right host when the docs share a deployment with the rest of your project.
When a project needs nothing but the docs, [Export a static site](../export-a-static-site/) writes
the same pages to a directory for any static file host. The export has no login gate and no
caching settings. The server that hosts it decides those.

---

# Export a static site

**Goal:** produce a directory a static file host can serve, identical to the running site, for a
deployment that needs the docs and nothing else.

Serving from your Django process is the primary way to run mdjango, and the one the rest of these
docs assume. The export exists for the case where no Django process will run: a docs-only host, a
preview bucket, an offline copy.

**You need:** the project configured as for runtime. The export uses the same settings, Content tree
and templates. The command's options are in the [`mdjango_build` reference](../../reference/mdjango-build/).

## Build the dist

```bash
python manage.py mdjango_build
```

```text
exported N pages + N text files + N static files to dist
```

The output directory defaults to `./dist`. Pass a path as the first argument to change it. The
layout mirrors the live URLs, one directory per page, with file-shaped URLs written as files:

```text
dist/
  docs/
    index.html                          # /docs/
    getting-started/
      install-and-mount/index.html      # /docs/getting-started/install-and-mount/
      install-and-mount.md              # the page's markdown alternate
    search-index.json
    llms.txt
    llms-full.txt
    index.md
  static/
    mdjango/                            # stylesheet, fonts, controllers, vendored JS
```

`docs/` is your mount prefix. `static/` is your `STATIC_URL`. The static destination is deleted and
rewritten on every build. Page files are overwritten in place.

## Build with the default static storage

The export copies mdjango's static tree with its plain filenames. If your production settings use a
manifest storage backend (hashed filenames, as WhiteNoise's `CompressedManifestStaticFilesStorage`
does), `{% static %}` writes hashed URLs into the HTML that the export never creates, and every
stylesheet and script 404s.

Run the export with the default `staticfiles` storage. Keep the manifest backend for the runtime
site only, or point `DJANGO_SETTINGS_MODULE` at a settings module that omits it for the build.

## Add your own static files

Only mdjango's own static tree is copied. A stylesheet you load after mdjango's, or an image linked
from a page, is referenced by the HTML but not written. Copy those into `dist/static/` after the
build:

```bash
python manage.py mdjango_build
cp -r acme-static/. dist/static/acme/
```

## Host it at the same prefix

The exported HTML references `/docs/…` and `/static/…` as absolute paths, the same ones the running
site uses. Serve `dist/` as the root of a host so that `/docs/` resolves to `dist/docs/index.html`.
Uploading only `dist/docs/` under a different prefix breaks every stylesheet, script and search
request. Nothing rewrites paths; the dist is not relocatable. To change the prefix, change the mount
in `urls.py` and rebuild.

To preview locally:

```bash
python -m http.server --directory dist 8000
```

Open <http://127.0.0.1:8000/docs/>. Search works from the exported `search-index.json`. Dark mode,
copy buttons and the drawer work from the exported controllers. Nothing is fetched from the network.

## Export without drafts

Drafts are exported when `MDJANGO_INCLUDE_DRAFTS` is true, which defaults to `DEBUG`. Run the export
with the settings you would deploy:

```bash
DJANGO_SETTINGS_MODULE=config.settings_production python manage.py mdjango_build
```

`MDJANGO_CACHE_SECONDS` has no effect on the export. The file server sets its own headers, and
there is no login gate: anything that needs authentication stays on the Django process.

## Know the limits

- Without a root `_index.md` the first Page is written twice: once at `/docs/` and once at its own
  URL. No canonical link is emitted.
- Non-markdown files in the Content tree are not copied.
- The export is a management command, not a standalone tool. The build environment needs the
  project's settings importable.

---

# Make your docs discoverable

**Goal:** let search engines crawl the docs — a `sitemap.xml` listing every page, a `robots.txt`
that points at it, and a clear line on what mdjango does *not* own.

**You need:** a served Content tree. This is the human-crawler counterpart of
[Publish the LLM artifacts](../publish-llm-artifacts/); the routes are in the
[URLs reference](../../reference/urls/).

The key fact shapes everything below: **mdjango has no domain.** It mounts under a prefix you choose
and emits root-relative URLs, so it stays portable across mounts and static exports. A sitemap needs
*absolute* URLs, and `robots.txt`/`.well-known` are only honoured at your **site root** — which your
project owns, not the docs app. So mdjango ships the one part it alone can (the page list) and leaves
the root to you.

## Add a sitemap

mdjango ships a `Sitemap` class; you mount the standard Django sitemap view at your site root. It
needs no extra package — `django.contrib.sitemaps` is part of Django, and it does **not** require
`django.contrib.sites` (it reads the domain from the request).

```python
# settings.py
INSTALLED_APPS += ["django.contrib.sitemaps"]
```

```python
# urls.py
from django.contrib.sitemaps.views import sitemap
from django.urls import include, path

from mdjango.sitemaps import DocsSitemap

sitemaps = {"docs": DocsSitemap}

urlpatterns = [
    path("sitemap.xml", sitemap, {"sitemaps": sitemaps}),  # at the root, not under docs/
    path("docs/", include("mdjango.urls")),
]
```

`GET /sitemap.xml` now lists every HTML page — the Index page and every Page in navigation order —
as absolute URLs, with the scheme and domain taken from the request. The machine artifacts
(`llms.txt`, `llms-full.txt`, `search-index.json`, the `.md` alternates) are deliberately excluded:
they are for agents, not a search index. Drafts are excluded too.

> Behind a TLS-terminating proxy, set `SECURE_PROXY_SSL_HEADER` so Django builds `https://` URLs.
> This is ordinary Django deployment config, not an mdjango setting.

## Date your pages

A page can declare when it last changed, which becomes its sitemap `<lastmod>`:

```markdown
---
title: Roll back a deploy
updated: 2024-03-15
---
```

The key is optional and per-page: pages without it simply carry no `<lastmod>`. Use an ISO
`YYYY-MM-DD` date; a malformed value is ignored with a build warning. mdjango does **not** fall back
to the file's modification time — a `git clone` or CI checkout stamps every file with the checkout
time, which would make `<lastmod>` identical and wrong across the whole site.

## Add a sitemap to a static export

A [static export](../export-a-static-site/) has no request to read a domain from, so pass one:

```bash
python manage.py mdjango_build dist --base-url https://docs.example.com
```

This writes `dist/sitemap.xml` — at the export **root**, so it is served from your domain root —
with absolute `<loc>`s and `<lastmod>` from any `updated` dates. Without `--base-url` no sitemap is
written. The domain is a build-time argument, never a setting: mdjango stays domain-agnostic
everywhere else.

## Add a robots.txt

`robots.txt` is only read at your domain root, so mdjango can't serve it from under the docs mount —
you add it. A permissive file that points crawlers at the sitemap:

```text
User-agent: *
Allow: /

Sitemap: https://docs.example.com/sitemap.xml
```

Serve it however you serve other root files — a static file, or a small view:

```python
# urls.py
from django.http import HttpResponse
from django.urls import path
from django.views import View


class RobotsTxt(View):
    def get(self, request):
        sitemap_url = request.build_absolute_uri("/sitemap.xml")
        body = f"User-agent: *\nAllow: /\n\nSitemap: {sitemap_url}\n"
        return HttpResponse(body, content_type="text/plain")


urlpatterns += [path("robots.txt", RobotsTxt.as_view())]
```

Leave the LLM artifacts and search index crawlable. Duplicate-content dilution is not a real concern
for docs, and the [llms.txt convention](https://llmstxt.org/) wants those files reachable.

## What mdjango leaves to you

`/.well-known/` (ACME challenges, `security.txt`, and the like) is a site-root concern with nothing
documentation-shaped in it — mdjango neither serves nor documents it beyond this line. The llms.txt
convention puts `llms.txt` at the site root, not under `.well-known`, so there is nothing there to
point at the docs.

---

# Settings

All configuration is ordinary Django settings. mdjango reads them on every request through
`mdjango.conf.get_conf()`, so there is no restart to pick up a change under `runserver`.
`MDJANGO_CONTENT_DIR` is required. Everything else has a default. The Theme's colours and type are
set in CSS ([theme tokens](../theme-tokens/)), not here — the one setting that touches them,
`MDJANGO_EXTRA_CSS`, only *loads* your stylesheet; it carries no values.

## Content

| Setting | Type | Default | Effect |
|---|---|---|---|
| `MDJANGO_CONTENT_DIR` | `str` or `Path` | required | Root of the Content tree. |
| `MDJANGO_INCLUDE_DRAFTS` | `bool` | `DEBUG` | Include Pages marked `draft: true` in the registry, and so in navigation, search, the LLM artifacts and the export. |
| `MDJANGO_ALWAYS_REBUILD` | `bool` | `DEBUG` | Re-read the Content tree and rebuild the search index and LLM artifacts on every request. Also disables response caching. |

## Shell

| Setting | Type | Default | Effect |
|---|---|---|---|
| `MDJANGO_BRAND` | `str` | `"docs"` | Wordmark text in the header. Fallback for `<title>`. |
| `MDJANGO_SITE_TITLE` | `str` | `""` | `<title>`, and the `#` heading of `llms.txt` and `llms-full.txt`. Falls back to `MDJANGO_BRAND`. |
| `MDJANGO_HOME_URL` | `str` | `"/"` | Target of the wordmark link. |
| `MDJANGO_VERSION` | `str` | `""` | Display string rendered as a chip in the header. Hidden when empty. Not used for routing. |
| `MDJANGO_GITHUB_URL` | `str` | `""` | Renders a header link labelled `github`. Hidden when empty. |
| `MDJANGO_HEADER_LINKS` | iterable of `{"label": str, "url": str}` dicts or `mdjango.conf.HeaderLink` | `()` | Extra header links, in order, between the `llms.txt` links and the GitHub link. |
| `MDJANGO_EXTRA_CSS` | `str` or iterable of `str` | `()` | Stylesheet `{% static %}` name(s), linked after mdjango's own sheet — the supported way to override the [seven CSS seeds](../theme-tokens/) without shadowing a template. A string is one file; an iterable is several, in order. See [Change the colours and type](../../how-to/change-colours-and-type/). |
| `MDJANGO_ASSET_EXTENSIONS` | iterable of `str` | `png jpg jpeg gif svg webp` | Non-markdown file extensions served as [assets](../../how-to/write-a-page/) from the Content tree (case-insensitive, leading dot optional). Defaults to images; set it to widen (e.g. add `pdf`) or narrow what a page can reference beside itself. |

## LLM artifacts

| Setting | Type | Default | Effect |
|---|---|---|---|
| `MDJANGO_LLM_DOCS` | `bool` | `True` | Master switch for the `llms.txt`, `llms-full.txt`, `index.md` and per-page `.md` routes, the header and drawer links, the `<link rel="alternate">`, and the export of those files. `False` returns 404 from all four routes. Search is unaffected. |
| `MDJANGO_DESCRIPTION` | `str` | `""` | One-line summary rendered as a `>` blockquote under the title in `llms.txt` and `llms-full.txt`. |

## Caching

| Setting | Type | Default | Effect |
|---|---|---|---|
| `MDJANGO_CACHE_SECONDS` | `int` | `300` | TTL of the server-side page cache (Django's default cache backend, key `mdjango:page:<path>`, `@index` for the Index page) and the value of `Cache-Control: public, max-age=`. `0` disables the page cache and sets `Cache-Control: no-cache`. Ignored while `MDJANGO_ALWAYS_REBUILD` is true. An `ETag` is always emitted. |

## Validation

Nothing is checked at startup. There are no Django system checks. A wrong value surfaces on the
first request, as a 500 in a running site or as an error from `mdjango_build`.

| Fault | Raised |
|---|---|
| `MDJANGO_CONTENT_DIR` missing or empty | `django.core.exceptions.ImproperlyConfigured` |
| `MDJANGO_CONTENT_DIR` is not a directory | `mdjango.features.common.exceptions.ContentError` |
| a `MDJANGO_HEADER_LINKS` dict without `label` or `url` | `KeyError` |
| `MDJANGO_EXTRA_CSS` neither a string nor an iterable of them | `TypeError` |
| `MDJANGO_CACHE_SECONDS` not convertible with `int()` | `ValueError` or `TypeError` |

## Not settings

- `INSTALLED_APPS` must contain `"django_cotton"` and `"mdjango"`. `"django.contrib.staticfiles"` is
  needed to serve the Shell's assets. Order between the two apps does not matter.
- `STATIC_URL` is used by `mdjango_build` as the prefix of the exported static directory.
- There is no setting for the maximum tree depth (fixed at three), for disabling search, for the
  markdown extensions, for a canonical URL, or for a logo. (A stylesheet *does* have one now —
  `MDJANGO_EXTRA_CSS`, above.)
- There is no setting for analytics or other `<head>` scripts — a setting cannot safely carry raw
  markup. Inject them through the head slot instead; see
  [Add analytics and other head tags](../../how-to/add-head-tags/).
- There is no base-URL or domain setting. mdjango emits root-relative URLs, so it stays portable
  across mount prefixes. The sitemap needs absolute URLs, so it derives the domain from the request
  at runtime, or from the `mdjango_build --base-url` flag for the static export — never a setting.
  See [Make your docs discoverable](../../how-to/make-docs-discoverable/).

---

# Content tree rules

The Content tree is the directory `MDJANGO_CONTENT_DIR` points at. It is the only source of content.
There is no database, no admin and no registration step.

## Layout

| Level | What | Where |
|---|---|---|
| Section | one immediate subdirectory of the root | `content/<section>/` |
| Subsection | one subdirectory of a Section | `content/<section>/<subsection>/` |
| Page | one `.md` file | at the root, in a Section, or in a Subsection |
| Index page | the root `_index.md` | `content/_index.md` |
| Loose page | a Page at the root or directly in a Section | `content/about.md`, `content/<section>/page.md` |

- A directory inside a Subsection raises `ContentError`. There is no fourth level.
- Only `.md` files are read. Other files are ignored and nothing is copied to the export.
- Directories are walked in filename order, then sorted by weight.

## Index files

Recognised stems: `_index` and `index`. A file named `index.md` inside a Section or Subsection is
treated as that group's index file: its body is discarded and it gets no URL. Name a Page anything
else.

| Location | `title` | `weight` | body |
|---|---|---|---|
| content root | Index page title and Home link label | ignored | **rendered** as the mount-root page |
| Section or Subsection | group title | group order | **discarded** |

The root `_index.md` ignores `draft`. It is read unconditionally. A Section or Subsection with no
index file takes its title from the humanised directory name (`-` and `_` become spaces, first
letter capitalised, the rest lower-cased) and weight `100`.

## Front-matter

A leading block fenced by lines that are exactly `---`. Each line is split on its first `:`. The
key is lower-cased and stripped. The value is stripped of whitespace and of surrounding `'` or `"`.
Lines without a colon are skipped. No YAML: no lists, no nesting, no multi-line values. A block
without a closing `---` is treated as body. Unknown keys are ignored.

| Key | Type | Default | Notes |
|---|---|---|---|
| `title` | string | first `#` heading in the body, else humanised filename stem | |
| `weight` | integer | `100` | a non-integer value falls back to the default |
| `draft` | boolean | `false` | accepts `true`/`yes`/`on` and `false`/`no`/`off`, case-insensitive. Anything else is the default. |
| `description` | string | `""` | suffix of the Page's `llms.txt` entry. Page-level only. |
| `updated` | date | none | ISO-8601 `YYYY-MM-DD`. Feeds the Page's sitemap `<lastmod>`; omitted when absent. A malformed value is ignored with a build warning (never file mtime — a checkout would make it wrong). Page-level only. See [Make your docs discoverable](../../how-to/make-docs-discoverable/). |

The first-heading fallback matches a `#` line with up to three leading spaces and optional closing
`#`s.

## Ordering

Siblings sort by `(weight, title.lower())`, lowest weight first, regardless of kind: Pages,
Subsections and Sections in the same parent share one sequence. A run of consecutive Loose pages at
the root renders as one untitled navigation group at its position and as a `## Documentation`
heading in `llms.txt`.

Previous/next links follow the flattened navigation order across Section and Subsection boundaries.
The Index page is not part of that order.

## Visibility

- A Page with `draft: true` is omitted unless `MDJANGO_INCLUDE_DRAFTS` is true.
- A Section or Subsection with no visible Pages is omitted from navigation, search, `llms.txt` and
  the export. This is not an error.

## Slugs and URLs

Slugs come from Django's `slugify` applied to the filename stem (Pages) or directory name (groups).
`slugify` lower-cases, so `Hello.md` and `hello.md` collide.

| Stored at | Path | Served at |
|---|---|---|
| `content/_index.md` | `""` | `/<mount>/` |
| `content/about.md` | `about` | `/<mount>/about/` |
| `content/how-to/deploy.md` | `how-to/deploy` | `/<mount>/how-to/deploy/` |
| `content/how-to/hosts/harden.md` | `how-to/hosts/harden` | `/<mount>/how-to/hosts/harden/` |

Sections and Subsections have no URL. Each Page also has a markdown alternate at the same path with
`.md` in place of the trailing slash (`/<mount>/how-to/deploy.md`; the Index page at
`/<mount>/index.md`).

## Errors

All are `mdjango.features.common.exceptions.ContentError`, raised when the registry is built. In a
running site that is the first request after a process start. No view catches it, so the request
returns a 500. `mdjango_build` catches it and exits with the message.

| Condition | Message contains |
|---|---|
| `MDJANGO_CONTENT_DIR` is not a directory | `content dir does not exist` |
| a directory inside a Subsection | `capped at three levels` |
| two files resolve to the same path | `duplicate page path` |

---

# URLs

Mount with `path("docs/", include("mdjango.urls"))`. Any prefix works. The URLconf sets
`app_name = "mdjango"`, so names reverse as `mdjango:<name>`. Examples below assume the `docs/`
prefix.

The namespace is fixed. Templates and services reverse routes as `mdjango:<name>`, so passing a
`namespace=` argument to `include()` or mounting the URLconf at two prefixes breaks every internal
link.

### Serve the docs at the site root

If the site *is* the docs, mount at the root instead of under `docs/`:

```python
urlpatterns = [
    # any other root routes (sitemap.xml, robots.txt, admin/, …) FIRST
    path("", include("mdjango.urls")),  # a catch-all — must come last
]
```

The `<path:page_path>/` route matches any path, so the mdjango include has to be the **last**
pattern: anything you serve at the root — `sitemap.xml`, `robots.txt`, the admin — must be declared
above it or the page view will shadow it with a 404. Reversed URLs and the export drop the prefix
accordingly (`mdjango:index` is `/`, a page is `/how-to/deploy/`). This is exactly how mdjango's own
docs site is mounted.

## Routes

| Path | Name | Response |
|---|---|---|
| `/docs/` | `mdjango:index` | HTML. The Index page, or the first Page in navigation order when there is no root `_index.md`. 404 when the tree has no Pages. |
| `/docs/<path>/` | `mdjango:page` | HTML. `<path>` is the Page path (`how-to/deploy`, `how-to/hosts/harden`). 404 for an unknown path. |
| `/docs/search-index.json` | `mdjango:search_index` | JSON array; see below. Always available. |
| `/docs/llms.txt` | `mdjango:llms_txt` | `text/markdown`. 404 when `MDJANGO_LLM_DOCS` is false. |
| `/docs/llms-full.txt` | `mdjango:llms_full` | `text/markdown`. 404 when `MDJANGO_LLM_DOCS` is false. |
| `/docs/index.md` | `mdjango:index_markdown` | `text/markdown`. The landing page's source. 404 when `MDJANGO_LLM_DOCS` is false. |
| `/docs/<path>.md` | `mdjango:page_markdown` | `text/markdown`. One Page's source. 404 when unknown or `MDJANGO_LLM_DOCS` is false. |

File-shaped routes carry no trailing slash and the HTML page route requires one. That keeps a Page
named `llms` from shadowing `llms.txt`. The `index.md` route is declared before `<path>.md` so it is
not read as a Page named `index`.

Markdown responses are served as `text/markdown; charset=utf-8`. The per-page `.md` is the source
body with front-matter stripped; a body that does not open with a heading gets `# <title>`
prepended.

## Reversing

```python
from django.urls import reverse

reverse("mdjango:index")                                    # /docs/
reverse("mdjango:page", args=["how-to/deploy"])             # /docs/how-to/deploy/
reverse("mdjango:page_markdown", args=["how-to/deploy"])    # /docs/how-to/deploy.md
reverse("mdjango:llms_txt")                                 # /docs/llms.txt
```

`mdjango.views.page_url(page)` and `mdjango.views.page_markdown_url(page)` do the same from a
`Page` object, handling the Index page's empty path.

## Sitemap

`sitemap.xml` is **not** a route `mdjango.urls` mounts. mdjango ships the `mdjango.sitemaps.DocsSitemap`
class; you mount the standard `django.contrib.sitemaps` view at your **site root** (not under the
docs prefix — a sitemap is a root resource), and Django supplies the domain from the request. See
[Make your docs discoverable](../../how-to/make-docs-discoverable/). `robots.txt` and `/.well-known/`
are your site root's concern too and mdjango ships neither.

## Headers

Every response carries a strong `ETag` (MD5 of the body). `Cache-Control` is
`public, max-age=<MDJANGO_CACHE_SECONDS>` when caching is enabled and `no-cache` otherwise. A
request with a matching `If-None-Match` receives `304 Not Modified`. No `Vary`, `Last-Modified` or
`s-maxage` is set. See [How caching works](../../explanation/how-caching-works/).

## Errors

| Situation | Response |
|---|---|
| unknown Page path | 404 |
| no Pages in the tree, at the mount root | 404 |
| any markdown route with `MDJANGO_LLM_DOCS = False` | 404 |
| `ContentError` or `ImproperlyConfigured` while reading the tree | 500 (uncaught) |

## Search index

`search-index.json` is a JSON array with one object per visible Page, the Index page first, then
navigation order:

| Field | Value |
|---|---|
| `id` | the Page path (`""` for the Index page) |
| `title` | Page title |
| `section` | ancestor trail as one string, `"Section / Subsection"`; empty for root Pages |
| `text` | the rendered page reduced to plain text: tags stripped, entities unescaped, whitespace collapsed. The full body, not a summary. |
| `url` | the Page's HTML URL |

The client indexes `title`, `section` and `text` with MiniSearch: title boosted ×3, section ×2,
prefix and fuzzy (`0.2`) matching on. Index and library are fetched on the first opening of the
palette, not on page load. Open with `/` or Ctrl/⌘-K; close with Esc. Building the index renders
every Page, so it is the most expensive build in the app.

---

# `mdjango_build`

```bash
python manage.py mdjango_build [output_dir] [--check] [--base-url URL]
```

Validate the Content tree and every page's render without writing, or export the site to a static
directory. This is the only command mdjango adds. There is no console script.

## Arguments

| Argument | Default | Meaning |
|---|---|---|
| `output_dir` | `dist` | Directory to write into. Created if missing. |
| `--check` | off | Validate content and render every page without writing. |
| `--base-url` | none | Site origin (`https://docs.example.com`) for an absolute-URL `sitemap.xml` at the dist root. Omitted: no sitemap is written. |

## Behaviour

1. Clears the in-process registry, search and LLM caches, then builds the registry from
   `MDJANGO_CONTENT_DIR` with the current settings. `MDJANGO_INCLUDE_DRAFTS` decides whether drafts
   are included. A `ContentError` becomes a `CommandError`.
2. Lists the targets: the page served at the mount root (the Index page, or the first Page in
   navigation order when there is none), then every Page in navigation order. Without a root
   `_index.md` the first Page is therefore a target twice, at `/<mount>/` and at its own URL.
3. With `--check`, renders each target through the same template and context the runtime views use,
   builds the search index and, when `MDJANGO_LLM_DOCS` is true, the LLM artifacts. Writes nothing.
   Stops here.
4. Otherwise renders each target and writes, under `output_dir`:
    - `<mount>/…/index.html`, one directory per page, mirroring the URL;
    - `<mount>/search-index.json`, minified;
    - when `MDJANGO_LLM_DOCS` is true: `<mount>/llms.txt`, `<mount>/llms-full.txt`,
      `<mount>/index.md` and one `<mount>/…/<page>.md` per Page;
    - `<STATIC_URL>/mdjango/…`, mdjango's own static tree copied whole. An existing directory at
      that destination is removed first. No other app's static files are copied.
    - with `--base-url`, `sitemap.xml` at the dist **root** (not under `<mount>`) — a sitemap is a
      site-root resource. Its `<loc>`s are `<base-url>` + each Page's path; a Page with an `updated`
      date also gets a `<lastmod>`. The machine artifacts are excluded. Without `--base-url` no
      sitemap is written: the runtime derives its own domain from the request, but the export has no
      request, so the domain must be supplied here.
5. Prints `exported N pages + N text files + N static files to <output_dir>`, then `wrote
   <output_dir>/sitemap.xml` when `--base-url` was given.

`<mount>` and `STATIC_URL` come from the project's URLconf and settings, and the HTML contains them
as absolute paths. The dist must be served at the same prefixes. Static URLs are whatever
`{% static %}` produced under the active storage backend; with a manifest backend they are hashed
names the export does not create.

## Output of `--check`

On success, to stdout:

```text
ok — N pages render, content valid
```

On failure, one line per problem to stderr, then a `CommandError`:

```text
<url>: <error>
search index: <error>
llm artifacts: <error>
CommandError: N page(s) failed to render
```

## Exit status

`0` on success. `1` on a `CommandError`: a content error, a render failure under `--check`. Any
other exception, such as an I/O error while writing or a missing `MDJANGO_CONTENT_DIR`, propagates
as a traceback with a non-zero status.

`MDJANGO_CACHE_SECONDS` has no effect on the command.

---

# Theme tokens

The Theme's palette is two colour Ramps. Each is defined by its two ends, which are Seed values a
Consumer sets. The stops between them are Derived values, computed with `color-mix()` in OKLab from
the ends of their own Ramp. All are CSS custom properties on `:root` in `mdjango.css`.

```text
surface ramp:  --background ..... --surface ..... --border
text ramp:     --foreground ..... --foreground-body ..... --foreground-muted ..... --foreground-subtle
```

## Seeds

The Override surface. These seven names are a versioned contract: renaming or removing one is a
breaking change.

| Token | Role | Light default | Dark default |
|---|---|---|---|
| `--background` | page ground; lightest surface | `#f7f3ea` | `#211c15` |
| `--border` | hairlines; darkest surface tone | `#e0d7c6` | `#3b3426` |
| `--foreground` | full-emphasis text | `#2b241c` | `#eae3d2` |
| `--foreground-subtle` | lowest-emphasis text | `#a2947f` | `#8a7f6b` |
| `--accent` | article links only | `var(--foreground)` | `var(--foreground)` |
| `--font` | body and chrome typeface | `"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace` | same |
| `--font-size` | root size; every other size is a fixed fraction of it | `14px` | same |

## Derived (locked)

| Token | Role | Light | Dark |
|---|---|---|---|
| `--surface` | raised surfaces: code blocks, inline code, search trigger, inline TOC | `color-mix(in oklab, var(--background) 59%, var(--border))` | same |
| `--foreground-body` | prose | `color-mix(in oklab, var(--foreground) 55%, var(--foreground-subtle))` | `48.6%` |
| `--foreground-muted` | nav links, TOC links, pager, blockquotes, table headers | `color-mix(in oklab, var(--foreground) 33%, var(--foreground-subtle))` | `19%` |

The dark theme restates the two text ratios because its ladder is compressed at the low-emphasis
end. Setting a derived token directly is not supported.

## Outside the contract

| Token | Value | Notes |
|---|---|---|
| `--code-font` | `"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace` | Code, `kbd`, `pre` and `samp`. Declared on `:root` beside the seeds, so it can be set, but it is not one of the seven and its name is not versioned. |

There are no spacing or layout-width tokens. Shell width, column widths, header height and the
two breakpoints (`1023px` drops the TOC rail; `767px` drops the sidebar into a drawer) are literals
in the stylesheet. There is no `--highlight` token.

## Dark mode selectors

The dark values are applied by two rules of equal specificity:

```css
:root[data-theme="dark"] { … }                                  /* reader pressed the toggle */
@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { … } }   /* system dark, no toggle */
```

The toggle sets `data-theme` on `<html>` and stores `"dark"` or `"light"` in `localStorage` under
`mdjango-theme`. An inline script in `<head>`, before the stylesheet, applies the stored value
before first paint. Once pressed there is no "follow the system" state until the key is cleared. A
Consumer's dark override must target both selectors.

## Type scale

Sizes are 4-decimal fractions of `--font-size`, giving 11 / 11.5 / 12 / 13 / 14 / 15 / 16 / 26 px at
the default, plus 10px for the nav caret and 18.2px for the mobile hamburger. Changing `--font-size`
scales chrome and prose together.

## Fonts

IBM Plex Mono is vendored: six `woff2` faces (400, 600 and 400 italic, each in latin and latin-ext
subsets, about 89KB) under `static/mdjango/fonts/`, licensed under the SIL Open Font License 1.1.
The `@font-face` rules are inside `mdjango.css` with paths relative to the stylesheet, so they work
from a static export on disk. `static/mdjango/fonts.css` carries the `@font-face` rules alone
(about 2KB) for pages mdjango does not render:

```django
<link rel="stylesheet" href="{% static 'mdjango/fonts.css' %}">
```

Nothing is loaded from a CDN. Stimulus and MiniSearch are vendored ESM files resolved through an
import map in the base component.

---

# Templates

mdjango renders every HTML response through one template, `mdjango/page.html`, which composes
cotton components from `cotton/docs/`. There are no `{% block %}` tags, no `{% extends %}`, no
template tags and no context processors. The one component a Consumer may override is the **head
slot** (`head_extra.html`) — shadow it to add analytics or other `<head>` tags; see
[Add analytics and other head tags](../../how-to/add-head-tags/). Every other template is a closed
surface. What else a Consumer may change is listed in [settings](../settings/) and
[theme tokens](../theme-tokens/).

## Page context

`mdjango.views.page_context(page)` builds this dict. Runtime views and the export both use it.

| Key | Type | Content |
|---|---|---|
| `conf` | `mdjango.conf.Conf` | resolved settings: `brand`, `home_url`, `version`, `github_url`, `header_links`, `site_title`, `description`, `llm_docs`, and `title` (site title or brand) |
| `page` | `Page` | the current Page: `path`, `title`, `weight`, `description`, `draft`, `source`, `body`, `section`, `subsection`, `groups` |
| `article_html` | `str` | rendered article, marked safe by the article component |
| `toc` | tuple of `TocItem(id, label, level)` | H2 and H3 headings; empty when the page has none |
| `home` | dict or `None` | `{kind, title, url, active}` for the Index page; `None` without a root `_index.md` |
| `nav` | list of dicts | groups `{title, loose, items}`; items are `{kind: "link", title, url, active}` or `{kind: "subgroup", title, open, items}` |
| `breadcrumb` | list of dicts | `{label, url}` per crumb, outermost group first, then the Page; `url` is always `""` |
| `eyebrow` | `str` | title of the Page's innermost group; `""` for root Pages |
| `prev`, `next` | `Page` or `None` | neighbours in flattened navigation order |
| `prev_url`, `next_url` | `str` | their URLs, or `""` |
| `page_md_url` | `str` | the `.md` alternate URL, or `""` when `MDJANGO_LLM_DOCS` is false |

## The page template

`mdjango/templates/mdjango/page.html` wraps everything in `<c-docs.base>`, then renders the header,
the drawer backdrop, a `.docs-shell` grid holding the sidebar, `<main>` (inline TOC, eyebrow,
article, pager) and the TOC rail, then the search dialog. When `toc` is empty both TOC instances
are omitted and the shell gets the `docs-shell--no-toc` class, which drops the rail column.

## Components

All under `mdjango/templates/cotton/docs/`. "Attributes" are passed explicitly by the page
template. "Outer context" is read from the page context without being passed.

| File | Renders | Attributes | Outer context |
|---|---|---|---|
| `base.html` | `<!doctype html>` through `</html>`: `<title>`, the `rel="alternate"` link, the no-flash theme script, the `mdjango.css` link, the import map, the head slot (`<c-docs.head-extra />`), `<body class="docs-body" data-controller="theme search disclosure">` with `{{ slot }}`, and the `application.js` module | `title`, `page_md_url` (default `""`) | — |
| `head_extra.html` | nothing — ships empty. A Consumer shadows it to inject `<head>` content (analytics, a verification `<meta>`, a preconnect); rendered at the end of `<head>`. See [Add analytics and other head tags](../../how-to/add-head-tags/) | — | — |
| `header.html` | hamburger, wordmark, breadcrumb, search trigger, version chip, `llms.txt` links, header links, GitHub link, theme toggle | `conf`, `breadcrumb` | — |
| `sidebar.html` | `<nav class="docs-sidebar">`: Home link, mobile `llms.txt` group, one group per nav entry with `<details class="docs-nav-sub">` for subgroups | `nav` | `home`, `conf` |
| `toc.html` | `<nav class="docs-toc docs-toc--{variant}">` with one link per heading, `data-controller="scrollspy"` | `items`, `variant` (default `rail`) | — |
| `eyebrow.html` | `<p class="docs-eyebrow">`; nothing when the label is empty | `label` (default `""`) | — |
| `article.html` | `<article class="article">{{ html\|safe }}</article>` | `html` | — |
| `pager.html` | `<nav class="docs-pager">` with prev/next links; an empty placeholder where a neighbour is absent | `prev`, `next`, `prev_url`, `next_url` | — |
| `breadcrumb.html` | `<nav class="docs-breadcrumb">`; crumbs are `<span>`s, never links | `items` | — |
| `search.html` | the search dialog, `data-search-index-url="{% url 'mdjango:search_index' %}"` | — | — |

## Stimulus controllers

Booted by `application.js`, which registers five controllers. `theme`, `search` and `disclosure`
mount on `<body>`.

| Identifier | Mounted on | Does |
|---|---|---|
| `theme` | `<body>` | flips `data-theme` on `<html>` and persists it to `localStorage` |
| `search` | `<body>` | the palette: `/` and Ctrl/⌘-K open it, fetches MiniSearch and the index on first open, keyboard navigation |
| `disclosure` | `<body>` | opens and closes the mobile navigation drawer |
| `scrollspy` | each `<c-docs.toc>` | marks the TOC link of the heading nearest the top of the viewport |
| `clipboard` | each code block, injected by the renderer | copies the block's text; shows "copied" for 1.6 seconds |

## Static assets

Under `mdjango/static/mdjango/`, all vendored:

| Path | Purpose |
|---|---|
| `mdjango.css` | the compiled stylesheet, `@font-face` rules included |
| `fonts.css` | the `@font-face` rules alone |
| `fonts/*.woff2`, `fonts/LICENSE.txt` | IBM Plex Mono, six faces, OFL 1.1 |
| `application.js` | Stimulus boot; registers the five controllers |
| `controllers/{theme,clipboard,scrollspy,disclosure,search}_controller.js` | one file per controller |
| `vendor/stimulus.js`, `vendor/minisearch.js` | ESM builds, resolved by the import map in `base.html` |

---

# Markdown

Pages are rendered by Python-Markdown with a fixed extension set. There is no setting to change it.
Output is HTML5 (`output_format="html"`), tab length 4, one fresh parser per page.

## Extensions

| Extension | Provides |
|---|---|
| `toc` | heading `id`s and `#` permalinks, `toc_depth="2-4"`, permalink title "Link to this section" |
| `tables` | pipe tables |
| `attr_list` | `{: .class #id }` attribute lists on block and inline elements |
| `def_list` | definition lists (`Term` / `:   Definition`) |
| `md_in_html` | markdown inside `<div markdown="1">` blocks |
| `sane_lists` | list numbering follows the source; a list does not continue across a different marker |
| `pymdownx.superfences` | fenced code anywhere, including nested in lists and blockquotes |
| `pymdownx.highlight` | Pygments highlighting; `guess_lang=False`, `pygments_lang_class=True` |
| `pymdownx.betterem` | stricter emphasis parsing |
| `pymdownx.tilde` | `~~strikethrough~~` and `~subscript~` |
| `pymdownx.saneheaders` | `#` starts a heading only when followed by a space |

## What the renderer adds

- **Table of contents**: H2 and H3 headings only, in document order, as `TocItem(id, label, level)`.
  An H4 gets an id and a permalink but is not listed.
- **Code fences**: each Pygments block is wrapped as `<div class="… highlight code-block"
  data-controller="clipboard">` with a `copy` button inserted as its first child. The button copies
  the block's text at click time. An untagged fence is not highlighted.
- **Permalinks**: `<a class="headerlink" href="#id">#</a>` after each H2 to H4. Anchored headings
  clear the sticky header.

## Not enabled

- `admonition` and `pymdownx.blocks`. `!!! note` renders as text. Use a blockquote.
- `footnotes`, `abbr`, `pymdownx.tasklist`, `pymdownx.tabbed`, `pymdownx.emoji`,
  `pymdownx.arithmatex`.
- Mermaid or any diagram rendering.
- Link rewriting for `<a href>` between pages. A relative link to another page is emitted as
  written. (The one exception is a relative reference — `<img src>` or `<a href>` — that resolves to
  a content-tree [asset](../../how-to/write-a-page/); that is rewritten to the asset's served URL.
  A link to another *page* is extensionless and never an asset, so it always passes through.)
- HTML sanitisation. The Content tree is treated as trusted. Raw HTML in a page, including inside
  `<div markdown="1">`, reaches the browser as written.

## Front-matter

Stripped before rendering; see [content tree rules](../content-tree/). The body is what is rendered,
and also what the `.md` alternate and `llms-full.txt` publish.

---

# The filesystem is the source of truth

mdjango has no models, no migrations and no admin. Every URL it serves comes from a file in the
Content tree, and nothing else. This page explains what that means for the project that hosts it.

## What you operate

Nothing beyond the Django process you already run. There is no table to migrate, no import command
to schedule, no editing interface to back. The whole tree is parsed once into an in-memory registry
(Sections, Pages by path, a flattened order for prev/next) on the first request after a process
starts, and reused for the life of that worker. Under `DEBUG` the parse happens on every request
instead, so an edit shows up on reload.

The corollary is that the registry is per process. Four gunicorn workers hold four copies, each
built on that worker's first request. They agree because they read the same files, not because they
share state.

`git` is the editing workflow and the audit log. A change to the docs is a commit, a rollback is a
revert, a review is a diff. A content change reaches production the way a code change does: deploy
and restart. There is no file watcher, because the content only changes when the code does.

## What it assumes about your content

The article body is emitted without sanitisation, and markdown inside raw HTML is processed. That is
correct for content your team authors and reviews in the same pull requests as the code. It is
wrong for anything else. mdjango is not a wiki engine. Pointing `MDJANGO_CONTENT_DIR` at
user-writable storage would let anyone who can write a file there run script in your readers'
browsers.

The same assumption is what lets rendered pages be cached and served as identical bytes to every
visitor: a docs page depends on the tree and the settings, never on who is asking. See
[How caching works](../how-caching-works/).

## One pipeline, two outputs

The runtime views and the static export build the same template context and render the same
templates. The export is the runtime output written to disk. There is no second renderer to drift,
which is why `mdjango_build --check` is a faithful test of what the live site will do, and why the
export is a cheap secondary for a docs-only host rather than a separate product.

## What this rules out

A CMS-style editing surface for non-technical authors, a database-backed content model with an
import step, and a static-site generator with Django as a thin wrapper were all considered and set
aside. The first two add a second copy of the truth for data already well represented as files. The
third gives up the reason to use Django at all: the docs living inside a process that also serves
authenticated users, other apps and shared middleware.

---

# Why three levels

A Content tree may nest three deep: Section, Subsection, Page. A fourth directory level is an error.
This page explains what the cap buys and why it is a hard error rather than a setting.

## The cap is a promise about the sidebar

The walker could recurse to any depth. The limit was never about parsing. It is a contract between
the content and the Shell: the navigation can show the whole tree legibly, and an author who exceeds
what it can show is told when the tree is read, not left with a site that quietly degrades at depth
five.

The cap was originally two, Section and Page. It moved to three when a real Section grew long enough
that a flat, weight-ordered list could no longer say that several of its Pages were one family. The
number changed; the error-not-degradation property did not.

## Subsections are labels, not destinations

A Subsection groups Pages and nothing more. It has no URL. Its `_index.md` supplies only a title and
a weight. Every URL is still a Page, and every Page is still a file. Making groups addressable would
open a new URL class and a new collision (`cli.md` and `cli/` in one Section both wanting `…/cli/`),
so that remains an open question rather than a hidden feature. If a group needs an overview, write
an ordinary Page inside it.

## Interleaving by weight

A Subsection sorts among its Section's own Pages by the same `(weight, title)` key, so a group can
sit in the middle of a curated sequence. The same rule lets a Loose page at the root sit between
Sections. The navigation keeps its top level uniform by coalescing a run of Loose pages into one
untitled group.

`llms.txt` cannot follow the interleaving. Markdown headings are sequential, and once a `###` opens
there is no way back to Section level without repeating the heading. So inside a Section it lists
the Section's own Pages first, then each Subsection. `llms-full.txt` and the navigation keep the
true order.

## Collapsing, and why open state is not remembered

Subsections render as native `<details>` elements, open only for the group containing the current
page. Sections never collapse: they are the site's map. The open state is computed on the server
from the current page alone, because rendered pages are cached and served identically to every
visitor. Per-visitor state cannot exist in that HTML. Remembering it in `localStorage` was
rejected: with a full page load on every navigation it would flash the wrong state on each click.

## Why not a setting

A `MDJANGO_MAX_DEPTH` setting was considered and rejected. It would make the navigation's legibility
a function of Consumer configuration and hand you a knob whose other positions produce a
broken-looking site. The Theme's stance is that you cannot configure your way into a bad result. The
depth cap is part of that.

## What the cap costs you

Moving a Page into a Subsection changes its URL, and there is no redirect table. Every relative link
aimed at it shifts by one segment. Restructure in a single change and update the links in the same
commit. [Structure a content tree](../../how-to/structure-a-content-tree/) walks through the move.

---

# Why the house style is fixed

mdjango ships exactly one Theme. You can change its colours, its typeface and its size. You cannot
change its layout, its chrome or its personality. This is deliberate, and this page lays out the
trade.

## The drop-in promise

The point of mdjango is that installing it into a project gives that project a finished
documentation site. A neutral, fully themeable base would hand back the work it exists to remove:
choosing a type scale, tuning a palette, designing a sidebar. So the House style, flat, monochrome,
typographic, one layout, is fixed, and adopting mdjango means adopting it.

## Two ramps, set by their ends

What is open is the Override surface: the two ends of each colour Ramp, an accent, the font and the
base size. Seven CSS custom properties.

The interior stops of each Ramp (raised surfaces, body text, muted text) are Derived values,
computed from the ends and locked. That is what makes the surface safe: you cannot pick a body-text
colour that fails against your background, because you do not pick it. The ladder is computed from
the two colours you did pick.

The derivation mixes within a Ramp, never across the palette. An earlier model mixed text toward
the background, which averaged away the chroma of any tinted palette and shipped visibly greyer than
its design. Giving each Ramp its own two ends fixed that, and the exposed set grew from five tokens
to seven as a result.

Those seven names are a public API. Renaming or dropping one is a breaking change and is versioned
as one. The markup of the templates, their class names and their context keys are not part of that
promise, and mdjango may change them in any release.

## The shell is included, not extended

mdjango renders the whole page from your settings: header, navigation, article, table of contents,
prev/next. That is the product. There is no `{% block %}` to fill, no slot for extra chrome, no
plugin point for a footer or a logo. A block contract would freeze the markup into a public API and
take away the freedom to improve the Shell; a partial extension point would produce sites that are
half House style and half something else. A project that needs different chrome has outgrown
mdjango and should fork it.

Overriding the seeds is the exception, and it stays outside that markup: `MDJANGO_EXTRA_CSS` loads
your stylesheet after the Shell's own, so you re-colour and re-type the House style without touching
a template. [Change the colours and type](../../how-to/change-colours-and-type/) shows it.

## No build step, no CDN

Everything the Shell needs ships in the wheel: a precompiled stylesheet, vendored Stimulus and
MiniSearch, six `woff2` faces of the default font. A reusable app cannot assume the project has
Node, a bundler, network access at page load, or a permissive content-security policy. A font that
only sometimes arrives is not a House style.

For the same reason there are no utility classes. The package ships tokens and semantic classes,
and a Consumer's own pages can reuse the font through `fonts.css`.

## What you give up

- A different layout, a card-based or bordered restyle, an independent border hue, a logo in the
  header: not reachable. mdjango is the wrong tool if you need them.
- A proportional body face is reachable through `--font`. Code stays monospace.
- A configurable navigation depth: rejected, because it would let a project configure its way into
  a sidebar the Shell cannot render legibly. See [Why three levels](../why-three-levels/).

---

# Theme examples

mdjango ships **one** house style — flat, monochrome, typographic — and there is no menu of themes
to pick from. What a Consumer *can* change is the [Override surface](../why-the-house-style-is-fixed/):
the seven CSS seeds (the two ends of each colour Ramp, one accent, the body font, the base size),
set in a stylesheet you point [`MDJANGO_EXTRA_CSS`](../../how-to/change-colours-and-type/) at.

The six palettes below are **examples of what that hook expresses**, not themes mdjango offers.
Each is a ~40-line stylesheet setting colour seeds for light and dark plus one `--font`; the layout,
the spacing, and the IBM Plex Mono code face are the fixed house style in every one. Click any
screenshot to open it full size.

## Ocean

Blue accent, IBM Plex Sans — a humanist sans that pairs with the house mono.

<div class="compare">
<figure><a href="theme-gallery/ocean-light.png"><img src="theme-gallery/ocean-light.png" alt="Ocean palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/ocean-dark.png"><img src="theme-gallery/ocean-dark.png" alt="Ocean palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #f4f7fb;
  --border: #d6e0ec;
  --foreground: #16202e;
  --foreground-subtle: #6b7d93;
  --accent: #2563eb;
  --font: "IBM Plex Sans", system-ui, sans-serif;
}
:root[data-theme="dark"] {
  --background: #0e1620;
  --border: #263243;
  --foreground: #e6edf5;
  --foreground-subtle: #7c8a9c;
  --accent: #5b9dff;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #0e1620;
    --border: #263243;
    --foreground: #e6edf5;
    --foreground-subtle: #7c8a9c;
    --accent: #5b9dff;
  }
}
```

## Claret

Crimson accent, Playfair Display — a high-contrast didone serif for a more editorial voice.

<div class="compare">
<figure><a href="theme-gallery/claret-light.png"><img src="theme-gallery/claret-light.png" alt="Claret palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/claret-dark.png"><img src="theme-gallery/claret-dark.png" alt="Claret palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #fdf6f5;
  --border: #f0d9d6;
  --foreground: #2a1a1c;
  --foreground-subtle: #9a7b7d;
  --accent: #c02b4e;
  --font: "Playfair Display", Georgia, serif;
}
:root[data-theme="dark"] {
  --background: #1e1416;
  --border: #3a2529;
  --foreground: #f2e3e4;
  --foreground-subtle: #a3868a;
  --accent: #ff6b8a;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #1e1416;
    --border: #3a2529;
    --foreground: #f2e3e4;
    --foreground-subtle: #a3868a;
    --accent: #ff6b8a;
  }
}
```

## Forest

Green accent, Roboto Slab — a slab serif with a sturdier, more technical feel.

<div class="compare">
<figure><a href="theme-gallery/forest-light.png"><img src="theme-gallery/forest-light.png" alt="Forest palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/forest-dark.png"><img src="theme-gallery/forest-dark.png" alt="Forest palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #f4f8f2;
  --border: #d6e4d0;
  --foreground: #182119;
  --foreground-subtle: #6d8070;
  --accent: #2f8f4e;
  --font: "Roboto Slab", Rockwell, serif;
}
:root[data-theme="dark"] {
  --background: #101711;
  --border: #26331f;
  --foreground: #e4efe2;
  --foreground-subtle: #85977f;
  --accent: #58c07a;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #101711;
    --border: #26331f;
    --foreground: #e4efe2;
    --foreground-subtle: #85977f;
    --accent: #58c07a;
  }
}
```

## Graphite

Blue accent, Inter — a neutral geometric-humanist sans, near-black text on cool greys.

<div class="compare">
<figure><a href="theme-gallery/graphite-light.png"><img src="theme-gallery/graphite-light.png" alt="Graphite palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/graphite-dark.png"><img src="theme-gallery/graphite-dark.png" alt="Graphite palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #fbfbfd;
  --border: #d2d2d7;
  --foreground: #1d1d1f;
  --foreground-subtle: #86868b;
  --accent: #0071e3;
  --font: "Inter", -apple-system, "SF Pro Text", system-ui, sans-serif;
  --font-size: 15px;
}
:root[data-theme="dark"] {
  --background: #0b0b0d;
  --border: #2a2a2e;
  --foreground: #f5f5f7;
  --foreground-subtle: #86868b;
  --accent: #2997ff;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #0b0b0d;
    --border: #2a2a2e;
    --foreground: #f5f5f7;
    --foreground-subtle: #86868b;
    --accent: #2997ff;
  }
}
```

## Ember

Red accent, Montserrat — a geometric sans, crisp white to charcoal.

<div class="compare">
<figure><a href="theme-gallery/ember-light.png"><img src="theme-gallery/ember-light.png" alt="Ember palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/ember-dark.png"><img src="theme-gallery/ember-dark.png" alt="Ember palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #ffffff;
  --border: #e3e3e3;
  --foreground: #171a20;
  --foreground-subtle: #5c5e62;
  --accent: #e82127;
  --font: "Montserrat", "Gotham", "Helvetica Neue", Arial, sans-serif;
}
:root[data-theme="dark"] {
  --background: #0f1114;
  --border: #26292e;
  --foreground: #f4f4f4;
  --foreground-subtle: #8e9196;
  --accent: #ff3b3f;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #0f1114;
    --border: #26292e;
    --foreground: #f4f4f4;
    --foreground-subtle: #8e9196;
    --accent: #ff3b3f;
  }
}
```

## Indigo

Indigo accent, Space Grotesk — a grotesque sans over cool blue-slate and deep navy.

<div class="compare">
<figure><a href="theme-gallery/indigo-light.png"><img src="theme-gallery/indigo-light.png" alt="Indigo palette, light mode"></a><figcaption>Light</figcaption></figure>
<figure><a href="theme-gallery/indigo-dark.png"><img src="theme-gallery/indigo-dark.png" alt="Indigo palette, dark mode"></a><figcaption>Dark</figcaption></figure>
</div>

```css
:root {
  --background: #f6f9fc;
  --border: #e3e8ee;
  --foreground: #0a2540;
  --foreground-subtle: #697386;
  --accent: #635bff;
  --font: "Space Grotesk", "Sohne", system-ui, sans-serif;
  --font-size: 15px;
}
:root[data-theme="dark"] {
  --background: #0a0e27;
  --border: #232748;
  --foreground: #eef1f8;
  --foreground-subtle: #878db3;
  --accent: #8b85ff;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --background: #0a0e27;
    --border: #232748;
    --foreground: #eef1f8;
    --foreground-subtle: #878db3;
    --accent: #8b85ff;
  }
}
```

## Building one

Every palette here is the same shape: colour seeds set twice (once for the toggle, once for the
system-dark reader) and one `--font`. Nothing else is touched, and nothing is selected at runtime —
you commit to one and it *is* your docs. To build your own, follow
[Change the colours and type](../../how-to/change-colours-and-type/); the token names and their
Derived stops are in the [theme tokens reference](../../reference/theme-tokens/).

---

# How caching works

A docs page is the same bytes for every visitor. mdjango leans on that in three places, all governed
by one setting, `MDJANGO_CACHE_SECONDS`. This page explains the layers so the behaviour in
[Run in production](../../how-to/run-in-production/) is predictable rather than surprising.

## Layer one: the registry, once per process

The Content tree is read and parsed into a registry on the first request a worker handles, and held
for the life of that process. The search index and the two `llms.txt` artifacts are built the same
way, once, from that registry. Nothing watches the filesystem.

Under `MDJANGO_ALWAYS_REBUILD`, which defaults to `DEBUG`, all three are rebuilt on every request
instead. That is why an edit shows up on reload under `runserver` and does not on a deployed server.
In production, a content change is a deploy: the workers restart and read the tree again.

## Layer two: rendered HTML in Django's cache

Rendering a page means a markdown pass, Pygments over every code block, and a template render. The
result is stored in `CACHES["default"]` under the key `mdjango:page:<path>` for
`MDJANGO_CACHE_SECONDS`. A repeat request for the same path skips the render entirely.

The key is the path alone. That is safe for what a page depends on, the tree and the settings, as
long as neither changes while an entry is live. With the default in-process cache a restart clears
the entries along with the registry. With a shared backend such as Redis, a restart does not: the
old HTML keeps serving for up to the TTL, including a header or setting you just changed. Clear the
cache as a deploy step, or keep the number small.

Only the HTML page views use this layer. The search index and the markdown routes rebuild their
response from layer one on each request.

## Layer three: HTTP headers

Every response carries a strong `ETag`, the MD5 of its body, and `Cache-Control`. When caching is
on the header is `public, max-age=<MDJANGO_CACHE_SECONDS>`. Browsers and any CDN or proxy in front
of you may store the page and serve it without reaching Django. A request carrying a matching
`If-None-Match` gets a `304 Not Modified` with no body.

When caching is off (`MDJANGO_CACHE_SECONDS = 0`, or `MDJANGO_ALWAYS_REBUILD`) the header is
`no-cache`. The `ETag` is still sent, so conditional requests still get `304`s. A browser holding a
page revalidates every time and downloads it only when it changed.

## Why the default is public

For documentation anyone may read, `public` is the right default: it is what lets a CDN absorb the
traffic and what the static export's file server would send anyway. It is also the one setting that
is wrong for docs behind a login. `public` tells a shared cache that the response may be handed to
the next visitor, whoever they are. A project that gates the docs with authentication must set
`MDJANGO_CACHE_SECONDS = 0`. Nothing in mdjango detects that situation for you, because mdjango
knows nothing about your users.

## What is not cached

Settings are read on every request. Nothing about caching applies to the static export, which
writes files and lets the hosting server set its own headers. `mdjango_build` clears layer one
before it starts and ignores the setting.
