Formatting
One command —
npm run format
— formats both surfaces a Caspian app actually has: the authored
markup inside every component template, and the Python around it. Markup
runs first, Python last, and every markup rewrite must be
proved safe before it is written to disk.
App-level convention
Caspian ships no formatter. Like the
quality gate,
this is adopted by the application on top of the framework
— it formats
main.py,
src/**,
settings/**,
and tests/**,
never the framework under
.venv/.
djLint
Formats the markup inside every component template. Jinja-aware and it does not reflow text, so server blocks and PulsePoint expressions survive intact.
ruff format
Formats the Python. Runs after the markup, then the template call openings are rejoined to the house style described below.
The equivalence check
Every reformatted block is compared against the original and written back only if it is guaranteed to render identically. Anything unproven is skipped and reported.
The command
Formatting is deliberately not part of
npm run check,
which stays a read-only report. Run it when you want files rewritten:
Report without writing, or narrow the run
npm run format:check # exits non-zero if work remains
uv run python settings/format.py --markup # markup only
uv run python settings/format.py --python # Python only
Do not add a separate markup-formatting script — the flags above already narrow the run.
Markup first, Python last
The order is load-bearing, not cosmetic. Reformatting a template changes
how many lines its string literal spans, which changes how
ruff format
wraps the call that encloses it. Run them the other way round and a single
pass never converges — the second tool undoes the first.
This is a direct consequence of single-file components: the markup lives inside the Python file, so the two formatters are editing the same bytes from opposite ends.
Why every block is proved
djLint is a general HTML formatter, so its output is never
trusted blindly. The specific hazard: it will insert a newline between a
block tag and an adjacent inline or
<x-*> tag
— and in rendered HTML that newline is a space. The
formatter cannot know it is wrong, because a custom element's
display comes
from CSS it never sees. So the page gains a visible gap and nothing
reports an error.
Each block is therefore formatted, then checked against
settings/_markup_equivalence.py,
a tokenizer that decides whether the result is guaranteed
to render identically. Only proven blocks are written back; the rest are
left exactly as they were and printed with the reason:
skipped — djLint's output could not be proved to render identically:
src/app/crm/index.py
8: would trim whitespace around 'Only Active' inside <button>, whose
display is not known to be block-level
Component tags nest, but count as inline
<x-*>
tags are registered with djLint so a component tree indents instead of
sitting flat. The checker still treats them as inline, so
indenting tags already on separate lines is accepted while separating
two touching tags is refused.
Code is preserved by construction
<script>,
<style>,
<pre> and
<textarea>
bodies are masked out before djLint runs. Left visible, it reads the
slash-angle inside a JavaScript regex as a tag delimiter and rewrites
the expression.
Why not Prettier? It has no Jinja awareness: it de-indents server block tags to column 0 and joins consecutive closing tags onto one line. djLint understands them and leaves text alone, which is what keeps nested PulsePoint expressions readable.
The template call opening
The house style keeps the call and the start of its template on one line, with the markup beginning at column 0 on the next:
return html(r"""
<div>…</div>
""", name=name)
ruff format
will not produce that. Any call whose first argument is a multiline string
and which has other arguments gets exploded — in default and
preview style alike:
return html(
r"""
<div>…</div>
""",
name=name,
)
So the formatter rejoins the opening after ruff runs. The two steps feed
each other — rejoining the opening lets ruff pull the closing
parenthesis up on a single-argument call — so the pair is iterated to
a fixed point. That is also why
--check replays
the whole pipeline against a temporary copy of the tree instead of calling
ruff format --check,
which would flag every rejoined call as unformatted.
ruff format,
or leaving an editor's format-on-save enabled, re-splits every template
call opening. Rerun
npm run format
— do not hand-edit the call sites and do not reach for
# fmt: skip.
How it fits with the gate
npm run check:fix
runs the formatter first, then the safe ruff fixes, then
the full gate. That order means the lint fixes land on final line
positions instead of on lines the formatter is about to move. A formatting
problem never blocks the fixes or the gate.
| Command | Writes files? | What it does |
|---|---|---|
| npm run check | No | Types, lint, templates, tests — report only. See Testing & Quality Gate. |
| npm run format | Yes | Markup via djLint, then Python via ruff format. |
| npm run format:check | No | Same pipeline against a temporary copy; non-zero if work remains. |
| npm run check:fix | Yes | Format, then safe ruff fixes, then re-run the gate. |
Configuration
The formatter lives in
settings/format.py,
the equivalence checker in
settings/_markup_equivalence.py,
and both are covered by
tests/test_format.py,
which pins the checker in both directions: rewrites that must be accepted,
and rewrites that must never be. Install the tools with:
uv sync --group dev
djLint's options are passed as explicit flags from
settings/format.py
rather than a
[tool.djlint]
block, because blocks extracted from Python are formatted in a temporary
directory outside the project, where that config would not be found.