Compose templates with django-cotton: an include repeated 117 times is not a component #263
Labels
No labels
accessibility
authentication
breaking change
bug
documentation
enhancement
interface
internationalisation
observability
security
tier
1
tier
2
tier
3
tier/4
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Postulo/postulo#263
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
templates/partials/field.htmlis included 117 times.partials/field_feedback.htmltwenty-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 isa
with a=… b=… c=…chain that grows until it wraps, a partial silently sees the wholeparent 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.htmlandpartials/table/head.htmlare both at the point where the call site tells you less aboutwhat 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:
Attributes become variables,
{{ slot }}is the content,<c-vars>declares defaults, anda 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:
templating and nothing else.
django>=4.2,<7.0, so 6.1 isin range. Nothing vendored into
static/js/.partials do now.
And the tooling needs no change:
assets/css/app.css:10is@source "../../src/postulo",which covers
templates/cotton/without a config line.tests/test_template_lint.pyalready lints them. ItsTEMPLATESglob isrglob("*.html")undersrc/postulo, so both the multiline-{# #}check and thephysical-sides check apply to a component the day it is written.
scripts/messages.py extracttreats 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:
picker in
settings/locale.htmlis a<details>disclosure of radio rows carrying aflag, a
lang-tagged name and a translation state. As a{% include %}with thecontext it needs, that is unpleasant; as
<c-language-picker>it is one tag.overlay in
phone_widget.htmlbecomes<c-flag-select>.partials/field.htmland its 117 call sites; a component with a declared
helpattribute is the differencebetween adding a parameter and editing 117
withchains.What the spike has to answer
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.
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 widgettemplates under
templates/django/forms/widgets/— confirm cotton's loader composeswith the form renderer rather than fighting it.
.postability. Moving markup between files moves the#:source references, sothe conversion commit will touch catalogues without changing a single string. Expected,
but the diff should be confirmed to carry no
msgid/msgstrmovement(
git diff -U0 -- src/postulo/locale | grep '^[-+]msg').Suggested shape: convert
partials/field.htmland one table partial only, measure thediff 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-partialssolves a different problem this projectalso 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.
Landed as
e09be2a16— the spike's four answersThe compile layer. Cotton's loader compiles
<c-…>tags to{% cotton %}tags when atemplate 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 configinstalls that by replacing
APP_DIRS: Truewith an explicit loader list at start-up. Nothingis written to disk, so the container needs nothing writable for it.
test_the_compiled_form_lives_in_memory_and_the_engine_still_cachespins 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.tokenizeglobally at app-ready(
nested_tag_support.py), hands Django the text around each{% cotton %}tag in pieces,and corrects each token's
linenobut not itsposition— which is whatTemplate.get_exception_inforeads for the debug page. So the debug page highlights thecomponent tag's line (2) rather than the
{% if %}on line 4. The file name is right, so thefault is still findable;
test_a_broken_page_is_reported_at_the_right_line_after_a_component_tagis a strict
xfailthat flips the day upstream fixes it. The fix is one line upstream(offset
token.positionby the chunk's start). I have not filed it there — say if you wantit filed under your name.
FORM_RENDERER = TemplatesSetting. Composes. Widget templates go through the sameengine, and the cotton loader passes any file without a
<c-tag through untouched. Thewhole 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)..postability.field.htmlandfield_feedback.htmlcarry no strings. The tableheader'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=Truebecomeserrors-only— and the three filesmoved 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(offby default) or
onlyon each call — and isolation renders every component in a freshRequestContext, which re-runs every context processor. Three fields cost seven extra passesof the
uiprocessor (a nested<c-field-feedback>counts as well), each asking for theinstance 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 bytest_every_component_declares_what_it_takes, and the trade-off is pinned bytest_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 ahelpattribute on<c-field>respectively. For #262 this was the ordering condition —"cotton goes first" — and it is met.