Compose templates with django-cotton: an include repeated 117 times is not a component #263

Closed
opened 2026-09-17 17:46:58 +00:00 by tiagoagueda · 1 comment
Owner

templates/partials/field.html is included 117 times. partials/field_feedback.html
twenty-one more. That is the right instinct — write it once — carried out with the only
mechanism Django gives us, and the mechanism is the problem.

{% include %} has no slots, no scoped variables and no encapsulation. Every parameter is
a with a=… b=… c=… chain that grows until it wraps, a partial silently sees the whole
parent context whether or not it should, and there is no way to give a component a default
for something the caller did not pass. partials/tags_field.html and
partials/table/head.html are both at the point where the call site tells you less about
what is happening than the partial does.

django-cotton is the current answer to exactly this. It
enhances Django's own template engine — no Jinja — and turns a partial into a tag:

<c-field :field="form.company_name" help="…" />
<c-card>
  <c-slot name="title">…</c-slot>
  …
</c-card>

Attributes become variables, {{ slot }} is the content, <c-vars> declares defaults, and
a component sees what it was given rather than whatever the caller happened to have.

Why it suits this project in particular

Every constraint that makes a front-end dependency expensive here does not apply:

  • No CSS, no client JavaScript, no new strings, no CSP surface. It is server-side
    templating and nothing else.
  • One dependency, and it is Django. Version 2.7.2, MIT, django>=4.2,<7.0, so 6.1 is
    in range. Nothing vendored into static/js/.
  • Components are templates, so they drop straight into htmx swaps exactly as the
    partials do now.

And the tooling needs no change:

  • Tailwind already scans them. assets/css/app.css:10 is @source "../../src/postulo",
    which covers templates/cotton/ without a config line.
  • tests/test_template_lint.py already lints them. Its TEMPLATES glob is
    rglob("*.html") under src/postulo, so both the multiline-{# #} check and the
    physical-sides check apply to a component the day it is written.
  • scripts/messages.py extract treats them as the ordinary Django templates they are.

Where it pays off immediately

Three open issues are all asking to reuse something that already exists, and all three are
awkward today for the same reason:

  • #208 — the flag in Server settings → Defaults, "as the language picker does". The
    picker in settings/locale.html is a <details> disclosure of radio rows carrying a
    flag, a lang-tagged name and a translation state. As a {% include %} with the
    context it needs, that is unpleasant; as <c-language-picker> it is one tag.
  • #214 — the country flag on postal addresses, "as the telephone field does". The
    overlay in phone_widget.html becomes <c-flag-select>.
  • #205 — help text on 167 of 240 form fields. That is a change to partials/field.html
    and its 117 call sites; a component with a declared help attribute is the difference
    between adding a parameter and editing 117 with chains.

What the spike has to answer

  • The compile layer. Cotton compiles components through a template loader with a
    cache. Find out where that cache lives and whether the container needs it writable —
    this is the one operational question, and it has to be answered before anything ships.
  • Error messages. A broken component should produce a traceback somebody can read. If
    a template error points at generated output rather than the file that was written, that
    is a real cost and should be weighed.
  • FORM_RENDERER = TemplatesSetting (config/settings/base.py:164) and the widget
    templates under templates/django/forms/widgets/ — confirm cotton's loader composes
    with the form renderer rather than fighting it.
  • .po stability. Moving markup between files moves the #: source references, so
    the conversion commit will touch catalogues without changing a single string. Expected,
    but the diff should be confirmed to carry no msgid/msgstr movement
    (git diff -U0 -- src/postulo/locale | grep '^[-+]msg').

Suggested shape: convert partials/field.html and one table partial only, measure the
diff across the 117 call sites, and decide from that rather than from the idea.

Relationship to #262

Orthogonal, and worth stating so neither blocks the other. Cotton is the composition
mechanism; whether the CSS inside a component is ours or Basecoat's is #262's question.
Either can land without the other.

If both are wanted, cotton goes first. With components in place, a CSS migration is a
per-component job; without them it is a per-template job across 179 templates. That
ordering is the main reason to decide this one soon even if it is not built soon.

Worth noting alongside: django-template-partials solves a different problem this project
also has — naming a fragment inside a template so an htmx response can render just that
block, instead of keeping a separate file for it. Not this issue, but the same area, and
the two compose.

`templates/partials/field.html` is included **117 times**. `partials/field_feedback.html` twenty-one more. That is the right instinct — write it once — carried out with the only mechanism Django gives us, and the mechanism is the problem. `{% include %}` has no slots, no scoped variables and no encapsulation. Every parameter is a `with a=… b=… c=…` chain that grows until it wraps, a partial silently sees the whole parent context whether or not it should, and there is no way to give a component a default for something the caller did not pass. `partials/tags_field.html` and `partials/table/head.html` are both at the point where the call site tells you less about what is happening than the partial does. [django-cotton](https://django-cotton.com/) is the current answer to exactly this. It enhances Django's own template engine — no Jinja — and turns a partial into a tag: ``` <c-field :field="form.company_name" help="…" /> <c-card> <c-slot name="title">…</c-slot> … </c-card> ``` Attributes become variables, `{{ slot }}` is the content, `<c-vars>` declares defaults, and a component sees what it was given rather than whatever the caller happened to have. ## Why it suits this project in particular Every constraint that makes a front-end dependency expensive here does not apply: - **No CSS, no client JavaScript, no new strings, no CSP surface.** It is server-side templating and nothing else. - **One dependency, and it is Django.** Version 2.7.2, MIT, `django>=4.2,<7.0`, so 6.1 is in range. Nothing vendored into `static/js/`. - **Components are templates**, so they drop straight into htmx swaps exactly as the partials do now. And the tooling needs no change: - Tailwind already scans them. `assets/css/app.css:10` is `@source "../../src/postulo"`, which covers `templates/cotton/` without a config line. - `tests/test_template_lint.py` already lints them. Its `TEMPLATES` glob is `rglob("*.html")` under `src/postulo`, so both the multiline-`{# #}` check and the physical-sides check apply to a component the day it is written. - `scripts/messages.py extract` treats them as the ordinary Django templates they are. ## Where it pays off immediately Three open issues are all asking to reuse something that already exists, and all three are awkward today for the same reason: - **#208** — the flag in *Server settings → Defaults*, "as the language picker does". The picker in `settings/locale.html` is a `<details>` disclosure of radio rows carrying a flag, a `lang`-tagged name and a translation state. As a `{% include %}` with the context it needs, that is unpleasant; as `<c-language-picker>` it is one tag. - **#214** — the country flag on postal addresses, "as the telephone field does". The overlay in `phone_widget.html` becomes `<c-flag-select>`. - **#205** — help text on 167 of 240 form fields. That is a change to `partials/field.html` and its 117 call sites; a component with a declared `help` attribute is the difference between adding a parameter and editing 117 `with` chains. ## What the spike has to answer - **The compile layer.** Cotton compiles components through a template loader with a cache. Find out where that cache lives and whether the container needs it writable — this is the one operational question, and it has to be answered before anything ships. - **Error messages.** A broken component should produce a traceback somebody can read. If a template error points at generated output rather than the file that was written, that is a real cost and should be weighed. - **`FORM_RENDERER = TemplatesSetting`** (`config/settings/base.py:164`) and the widget templates under `templates/django/forms/widgets/` — confirm cotton's loader composes with the form renderer rather than fighting it. - **`.po` stability.** Moving markup between files moves the `#:` source references, so the conversion commit will touch catalogues without changing a single string. Expected, but the diff should be confirmed to carry no `msgid`/`msgstr` movement (`git diff -U0 -- src/postulo/locale | grep '^[-+]msg'`). Suggested shape: convert `partials/field.html` and one table partial only, measure the diff across the 117 call sites, and decide from that rather than from the idea. ## Relationship to #262 Orthogonal, and worth stating so neither blocks the other. Cotton is the **composition** mechanism; whether the CSS inside a component is ours or Basecoat's is #262's question. Either can land without the other. If both are wanted, **cotton goes first.** With components in place, a CSS migration is a per-component job; without them it is a per-template job across 179 templates. That ordering is the main reason to decide this one soon even if it is not built soon. Worth noting alongside: `django-template-partials` solves a different problem this project also has — naming a fragment *inside* a template so an htmx response can render just that block, instead of keeping a separate file for it. Not this issue, but the same area, and the two compose.
tiagoagueda added this to the 0.4.0 milestone 2026-09-17 17:46:58 +00:00
Author
Owner

Landed as e09be2a16 — the spike's four answers

The compile layer. Cotton's loader compiles <c-…> tags to {% cotton %} tags when a
template file is loaded and keeps the compiled string in a dictionary keyed on the file's
path and mtime (CottonTemplateCacheHandler), inside Django's cached loader — its app config
installs that by replacing APP_DIRS: True with an explicit loader list at start-up. Nothing
is written to disk, so the container needs nothing writable for it.
test_the_compiled_form_lives_in_memory_and_the_engine_still_caches pins the shape.

Error messages. A broken component reports its own file and line (tested: line 3 of 3).
A broken page that uses a component reports the right file but, for a fault after the tag,
the wrong line: cotton patches Django's Lexer.tokenize globally at app-ready
(nested_tag_support.py), hands Django the text around each {% cotton %} tag in pieces,
and corrects each token's lineno but not its position — which is what
Template.get_exception_info reads for the debug page. So the debug page highlights the
component tag's line (2) rather than the {% if %} on line 4. The file name is right, so the
fault is still findable; test_a_broken_page_is_reported_at_the_right_line_after_a_component_tag
is a strict xfail that flips the day upstream fixes it. The fix is one line upstream
(offset token.position by the chunk's start). I have not filed it there — say if you want
it filed under your name.

FORM_RENDERER = TemplatesSetting. Composes. Widget templates go through the same
engine, and the cotton loader passes any file without a <c- tag through untouched. The
whole suite renders every form through it (6711 passed), and the browser suite passed
(105, plus the one Windows-only setup flake in test_submit_guard.py, green on rerun).

.po stability. field.html and field_feedback.html carry no strings. The table
header's move changed the #: source references in all 68 catalogues and nothing else:
git diff -U0 -- src/postulo/locale | grep '^[-+]msg' is empty on the commit.

Measured. 117 + 21 + 2 call sites in 44 templates. The call-site diff is one line per
site and mechanical — {% include "partials/field.html" with field=form.x %} becomes
<c-field :field="form.x" />, errors_only=True becomes errors-only — and the three files
moved with git mv, so their history follows them.

One premise did not survive. "A component sees what it was given rather than whatever
the caller happened to have" holds only with COTTON_ENABLE_CONTEXT_ISOLATION = True (off
by default) or only on each call — and isolation renders every component in a fresh
RequestContext, which re-runs every context processor. Three fields cost seven extra passes
of the ui processor (a nested <c-field-feedback> counts as well), each asking for the
instance name, the navigation and the installed version. Isolation stays off; the contract is
the <c-vars> line at the top of every component, kept by convention and by
test_every_component_declares_what_it_takes, and the trade-off is pinned by
test_isolation_would_run_every_context_processor_once_per_component.

Next. #208, #214 and #205 can now be a <c-language-picker>, a <c-flag-select> and a
help attribute on <c-field> respectively. For #262 this was the ordering condition —
"cotton goes first" — and it is met.

## Landed as e09be2a16 — the spike's four answers **The compile layer.** Cotton's loader compiles `<c-…>` tags to `{% cotton %}` tags when a template file is loaded and keeps the compiled *string* in a dictionary keyed on the file's path and mtime (`CottonTemplateCacheHandler`), inside Django's cached loader — its app config installs that by replacing `APP_DIRS: True` with an explicit loader list at start-up. Nothing is written to disk, so the container needs nothing writable for it. `test_the_compiled_form_lives_in_memory_and_the_engine_still_caches` pins the shape. **Error messages.** A broken *component* reports its own file and line (tested: line 3 of 3). A broken *page* that uses a component reports the right file but, for a fault after the tag, the wrong line: cotton patches Django's `Lexer.tokenize` globally at app-ready (`nested_tag_support.py`), hands Django the text around each `{% cotton %}` tag in pieces, and corrects each token's `lineno` but not its `position` — which is what `Template.get_exception_info` reads for the debug page. So the debug page highlights the component tag's line (2) rather than the `{% if %}` on line 4. The file name is right, so the fault is still findable; `test_a_broken_page_is_reported_at_the_right_line_after_a_component_tag` is a strict `xfail` that flips the day upstream fixes it. The fix is one line upstream (offset `token.position` by the chunk's start). I have not filed it there — say if you want it filed under your name. **`FORM_RENDERER = TemplatesSetting`.** Composes. Widget templates go through the same engine, and the cotton loader passes any file without a `<c-` tag through untouched. The whole suite renders every form through it (6711 passed), and the browser suite passed (105, plus the one Windows-only setup flake in `test_submit_guard.py`, green on rerun). **`.po` stability.** `field.html` and `field_feedback.html` carry no strings. The table header's move changed the `#:` source references in all 68 catalogues and nothing else: `git diff -U0 -- src/postulo/locale | grep '^[-+]msg'` is empty on the commit. **Measured.** 117 + 21 + 2 call sites in 44 templates. The call-site diff is one line per site and mechanical — `{% include "partials/field.html" with field=form.x %}` becomes `<c-field :field="form.x" />`, `errors_only=True` becomes `errors-only` — and the three files moved with `git mv`, so their history follows them. **One premise did not survive.** "A component sees what it was given rather than whatever the caller happened to have" holds only with `COTTON_ENABLE_CONTEXT_ISOLATION = True` (off by default) or `only` on each call — and isolation renders every component in a fresh `RequestContext`, which re-runs every context processor. Three fields cost seven extra passes of the `ui` processor (a nested `<c-field-feedback>` counts as well), each asking for the instance name, the navigation and the installed version. Isolation stays off; the contract is the `<c-vars>` line at the top of every component, kept by convention and by `test_every_component_declares_what_it_takes`, and the trade-off is pinned by `test_isolation_would_run_every_context_processor_once_per_component`. **Next.** #208, #214 and #205 can now be a `<c-language-picker>`, a `<c-flag-select>` and a `help` attribute on `<c-field>` respectively. For #262 this was the ordering condition — "cotton goes first" — and it is met.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Postulo/postulo#263
No description provided.