fix(notification): render body content, in every theme and both engines (#65) - #66
Conversation
…es (#65) Notification was the one container-shaped component in the set that did not accept a body. Every theme rendered the scalar `message` and nothing else, so the natural container form produced a correctly styled, correctly coloured, empty box. The two engines failed differently and the cotton one was the dangerous half. Cotton's `<c-vars message ...>` carried no default, so `message` resolved to the empty string and the box rendered silently. JinjaX's `message` was a required `{#def}` parameter, so a body-only call raised MissingRequiredArgument instead. Both now render the body when present and fall back to `message` when it is not. Existing `message=` callers are untouched and the JinjaX signature only loosens, so this is backward compatible in both directions. The escaping contract is asserted rather than inherited: JinjaX wraps slot content in `Markup`, so a body passes through each template's `{% autoescape true %}` block untouched, while `message` stays caller-supplied text and is still escaped. Both halves are tested per theme. tests/unit/test_notification_body.py adds 67 tests across the five themes: body renders, `message` still renders, body wins when both are given (and the loser is asserted absent), the JinjaX slot is a real slot, and two drift guards so a new theme cannot ship a Notification that ignores its body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Two defects in the previous commit, both found by probing the real engines
rather than reasoning about them.
A naive `{% if slot %}` reintroduces #65 with a new trigger. django-cotton's
CottonComponentNode.render sets `"slot": self.nodelist.render(context)` —
verbatim, unstripped — so a paired tag whose body renders empty still hands
the partial `"\n "`, which is truthy. The plausible call is a conditional
body:
<c-cf.notification message="Nothing to report">
{% if error %}{{ error }}{% endif %}
</c-cf.notification>
On the false branch that dropped `message` and rendered a styled empty box,
silently. Confirmed against the real cotton compiler before fixing, since
`render_to_string` bypasses it. Cotton now tests `slot.strip`; JinjaX already
strips its slot, but the Jinja side uses `content|trim` so the two engines
cannot disagree about what counts as a body.
The drift guard was vacuous and two substring needles failed to fix it.
`"content" in src` matches fomantic's `<div class="content">` wrapper, and the
tightened `"content if content"` still matches the
`{% set content = content if content is defined %}` StrictUndefined guard every
theme carries — both passed on a fomantic template with the fallback
expression deleted. It now renders instead of grepping, and enumerates themes
off the filesystem rather than from the hand-written THEMES list, so a sixth
theme fails the list check instead of silently escaping every parametrized
test in the file.
Both new guards were mutation-tested: stripping `slot.strip`/`|trim` turns all
ten whitespace tests red, and deleting fomantic's fallback expression turns the
render guard red on that theme alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
#65) The unit suite proves the ten templates render a body, but it cannot prove django-cotton builds `slot` the way the fix assumes: `render_to_string` bypasses the compiler, so `slot` is whatever the test injected. The integration tier is no better — its settings module never installs `django_cotton`, so `<c-cf.*>` tags reach the response as literal text and its existing assertions pass on Django variable interpolation alone. That gap is why #65 went unnoticed for three months, so the fix does not get to rely on the same tiers. Adds a `/notification/` gallery page exercising all three cotton call forms and asserts them in E2E, where cotton itself builds the slot: - the body form renders its body - the `message=` form still renders (no regression) - a conditional body on its false branch falls back to `message` rather than emitting a styled empty box Both halves of the fix were mutation-checked against these: reverting the bulma partial to bare `{{ message }}` reddens only the body test, and weakening `slot.strip` to `slot` reddens only the whitespace-fallback test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
) Broadening notification's content channel made an existing wrapper wrong. Foundation put the scalar `message` in a `<p>`, which was fine while the only thing that could land there was a string. A body is arbitrary markup, and the HTML parser does not tolerate block content inside an open paragraph — it closes it. Verified in Chromium: <div class="callout"><p><ul><li>Item</li></ul></p></div> parses to <div class="callout"><p></p><ul><li>Item</li></ul><p></p></div> The body is reparented onto `.callout` and two empty paragraphs remain, which Foundation's own `.callout > :first-child` / `> :last-child` margin rules then match instead of the content. No attribute or stylesheet can prevent it. The `<p>` now wraps only `message`, so the scalar form renders byte-identically to before and a body is emitted where the caller wrote it. Fomantic's `<div class="content">` and bootstrap/daisy's `<span>` both keep a `<ul>` nested where written (same probe), so they are unchanged. The two new tests assert the emitted markup rather than the parsed DOM, so they run at the unit tier in both engines; a third derives the wrapper list from the shipped templates so a sixth theme choosing `<p>` cannot silently disable them. Restoring the `<p>` around the body reddens exactly those two and nothing else. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf
Adversarial review — PR #66Scope: the full PR diff (14 files, 5 themes × 2 engines + tests + docs + changelog). One finding, confirmed and fixed in
|
Closes #65.
Notificationwas the only container-shaped component in the set that ignored body content. Every theme, both engines, rendered the scalarmessageprop and nothing else — so<c-cf.notification type="danger">{{ error }}</c-cf.notification>produced a correctly styled, correctly coloured, empty box. In cotton this failed silently; in JinjaX it raisedMissingRequiredArgument, becausemessagewas a required{#def}parameter with nowhere for a body to land.What changed
Ten templates — five themes × two engines — plus the cotton wrapper.
Cotton partials now render the body when one is present and fall back to
messagewhen it is not, each wrapped in that theme's own element (bulma bare, bootstrap/daisy<span>, fomantic<div class="content">, foundation<p>). The whole conditional stays on one physical line: the Django template language has no whitespace-control syntax, and djLint's reformat pass must not be given a chance to introduce whitespace that changes rendered bytes.The cotton wrapper gains
message=""in its<c-vars>. Without a default, django-cotton resolved a body-form call'smessageto the empty string — which is precisely how this rendered an empty box instead of erroring.Jinja templates declare
content=""and demotemessageto optional, so a body-only call is now valid. The signature only loosens; no existing call form changes.A body that renders to nothing is not a body
django-cotton hands the partial
nodelist.render(context)verbatim, so a paired tag whose body renders empty still supplies"\n "— truthy. A naive{% if slot %}would therefore suppressmessagefor any caller writing a conditional body, which is this same bug with a new trigger. Both engines treat a whitespace-only body as absent (slot.strip/content|trim).Foundation's
<p>wraps the message, not the bodyBroadening the content channel made an existing wrapper wrong. Foundation put the scalar in
<p>{{ message }}</p>, which was correct while only a string could land there. A body is arbitrary markup, and the HTML parser closes an open paragraph on block content — verified in Chromium, not assumed:<div class="callout"><p><ul>…</ul></p></div>parses to<div class="callout"><p></p><ul>…</ul><p></p></div>, reparenting the body onto.calloutand leaving two empty paragraphs for Foundation's own.callout > :first-child/> :last-childmargin rules to match.The
<p>now wrapsmessagealone, so the scalar form renders byte-identically to before. The same probe cleared the other four: fomantic's<div class="content">and bootstrap/daisy's<span>keep a<ul>nested where written, and bulma has no wrapper. Only<p>restructures.Escaping
The two operands of
{{ content if content else message }}want opposite treatment and get it. JinjaX wraps slot content inMarkup, so a body passes through the template's{% autoescape true %}block untouched;messageis caller-supplied text and is still escaped. Both halves are asserted per theme rather than left to autoescape semantics.Tests
tests/unit/test_notification_body.py— 67 tests covering the issue's four suggested cases across every theme and both engines, plus a drift guard asserting every shipped notification template references its body channel.tests/e2e/test_bulma_cotton.py— three call forms through the real compiler. This tier exists in the PR deliberately: the bug survived three months because no tier that runs the django-cotton compiler looked at this component's content channel. The unit tier injectsslotas raw context, and the integration tier never installsdjango_cottonat all, so its<c-cf.*>tags reach the response as literal text and its assertions pass on Django variable interpolation alone. A fix for #65 should not be validated only by the tiers that missed #65.Both halves were mutation-checked: reverting the bulma partial to bare
{{ message }}reddens only the body test; weakeningslot.striptoslotreddens only the whitespace-fallback test. Neither mutation disturbed the other assertions.Gates
2383 passed, 20 skipped(unit + integration) ·236 passed, 33 skipped(E2E, chromium) ·ruff check/ruff format --check src testsclean ·prek run --all-filesclean, both djLint trees included.Note on scope
Docs (
docs/components.md) gained thecontentprop row and the escaping note. No consumer migration is required — this is purely additive to the prop contract.🤖 Generated with Claude Code
https://claude.ai/code/session_01NhqNRBg83czKfr8L6FF5xf