diff --git a/CHANGELOG.md b/CHANGELOG.md index 834a6b4..5eb9ad5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ ## [Unreleased] +### Fixed — `Notification` silently discarded its body content (#65) + +- **Every theme, both engines, rendered the scalar `message` prop and nothing + else.** A caller writing the natural container form — + `{{ error }}` — got a + correctly styled, correctly coloured, **empty** box. In cotton this failed + silently: `` carried no default, so `message` resolved to + the empty string and the box rendered anyway. In JinjaX it failed loudly but + wrongly — `message` was a required `{#def}` parameter, so a body-only call + raised `MissingRequiredArgument`. Both paths now render the body when one is + present and fall back to `message` when it is not. `message=` callers are + untouched; the JinjaX signature only loosens. +- `Notification` was the single outlier among the container-shaped components. + `card`, `modal`, `panel`, `prose` and `box` have always accepted a body + alongside their scalar props; the bodiless components (`breadcrumb`, + `pagination`, `table`, `progress`) are all data-driven, where children would + be meaningless. `Notification` is a container that happened to expose only a + string. +- **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. Without a guard, a `message=` + caller writing a conditional body would get an empty box on the false branch, + which is this same bug with a new trigger. Both engines now treat a + whitespace-only body as absent and fall back to `message`. +- **The two operands want opposite escaping and now get it, under test.** JinjaX + wraps slot content in `Markup`, so a body passes through the template's + `{% autoescape true %}` block untouched; `message` is caller-supplied text and + is still escaped. Both halves are asserted per theme rather than left to + autoescape semantics. +- **Foundation's `

` now wraps only `message`, not the body.** A body is + arbitrary markup, and the HTML parser closes an open `

` on encountering + block content: `

` parses to `

`, which + reparents the body onto `.callout` and leaves two empty paragraphs for + Foundation's own `.callout > :first-child` / `> :last-child` margin rules to + match. Broadening the content channel is what made that reachable — before + this, the `

` only ever held a string. `message=` callers render + byte-identically to before. Fomantic's `

` and + bootstrap/daisy's `` hold block content without being restructured, and + are unchanged. +- **Covered at the tier that could have caught it.** The bug survived three + months because no tier that ran the django-cotton compiler looked at this + component's content channel: the unit tier injects `slot` as raw context + (`render_to_string` bypasses the compiler), and the integration tier does not + install `django_cotton` at all, so its `` tags reach the response as + literal text. The three cotton call forms — body, `message=`, and a + conditional body on its false branch — are now asserted in the E2E tier, + where `slot` is built by cotton itself. + ## [0.3.0] — 2026-07-31 The primitives layer. 0.2.0 shipped 14 *structural* components — card, modal, diff --git a/docs/components.md b/docs/components.md index 29fbf49..e9e3bdf 100644 --- a/docs/components.md +++ b/docs/components.md @@ -146,11 +146,15 @@ Focus trapping, focus restoration, and `Escape`-to-close live in | Prop | Default | Notes | |---|---|---| -| `message` | *required* | | +| `content` | `""` | Body — the slot in template position; wins over `message` | +| `message` | `""` | Scalar alternative to the body, for a plain string | | `type` | `"info"` | `info`, `success`, `warning`, `danger` | | `dismissible` | `true` | Renders a close button | | `extra_class` | `""` | | +Supply one or the other. A body passed as slot content is already-rendered +markup and is not escaped again; `message` is caller-supplied text and is. + ### `Cf:Progress` / `` | Prop | Default | Notes | diff --git a/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html index dc367dd..005551f 100644 --- a/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html +++ b/src/cf_ui/templates/cotton/_themes/bootstrap/notification.html @@ -4,7 +4,9 @@ x-data="{ visible: true }" x-show="visible" > - {{ message }} + {% comment %} Body content wins over the scalar `message` — a caller who wrote children + meant them. `message` stays for the existing call form (#65). {% endcomment %} + {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %} {% if dismissible == "true" %} {% endif %} - {{ message }} + {% comment %} Body content wins over the scalar `message` — a caller who wrote children + meant them. `message` stays for the existing call form (#65). {% endcomment %} + {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
diff --git a/src/cf_ui/templates/cotton/_themes/daisy/notification.html b/src/cf_ui/templates/cotton/_themes/daisy/notification.html index 6646223..e7036f1 100644 --- a/src/cf_ui/templates/cotton/_themes/daisy/notification.html +++ b/src/cf_ui/templates/cotton/_themes/daisy/notification.html @@ -4,7 +4,9 @@ x-data="{ visible: true }" x-show="visible" > - {{ message }} + {% comment %} Body content wins over the scalar `message` — a caller who wrote children + meant them. `message` stays for the existing call form (#65). {% endcomment %} + {% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %} {% if dismissible == "true" %} {% endif %} -
{{ message }}
+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children + meant them. `message` stays for the existing call form (#65). {% endcomment %} +
{% if slot.strip %}{{ slot }}{% else %}{{ message }}{% endif %}
diff --git a/src/cf_ui/templates/cotton/_themes/foundation/notification.html b/src/cf_ui/templates/cotton/_themes/foundation/notification.html index 37cc95a..8e019d8 100644 --- a/src/cf_ui/templates/cotton/_themes/foundation/notification.html +++ b/src/cf_ui/templates/cotton/_themes/foundation/notification.html @@ -10,7 +10,33 @@ x-data="{ visible: true }" x-show="visible" > -

{{ message }}

+ {% comment %} Body content wins over the scalar `message` — a caller who wrote children + meant them. `message` stays for the existing call form (#65). + + The ` +

+` wraps only `message`. A body is arbitrary markup, and the HTML + parser closes an open ` +

+` on encountering block content: ` +

+

+

+` + parses to ` +

+

+ +

+

+`, reparenting the body onto `.callout` and + leaving two empty paragraphs behind for `.callout > :last-child` to match. + `message` is text, so its wrapper renders byte-identically to before. {% endcomment %} + {% if slot.strip %}{{ slot }}{% else %}

{{ message }}

{% endif %} {% if dismissible == "true" %} {% endif %} - {{ message }} + {# Body content wins over the scalar `message` — a caller who wrote children + meant them. The two operands want opposite escaping and get it: JinjaX + wraps slot content in `Markup`, so it passes through this block + untouched, while `message` is caller-supplied text and is escaped. #} + {{ content if content|trim else message }} {% endautoescape %} diff --git a/src/cf_ui/templates/jinja/daisy/Notification.jinja b/src/cf_ui/templates/jinja/daisy/Notification.jinja index 925e135..c6c04dd 100644 --- a/src/cf_ui/templates/jinja/daisy/Notification.jinja +++ b/src/cf_ui/templates/jinja/daisy/Notification.jinja @@ -1,5 +1,7 @@ -{#def message, type="info", dismissible=true, extra_class="" #} +{#def content="", message="", type="info", dismissible=true, extra_class="" #} {% autoescape true %} + {% set content = content if content is defined else "" %} + {% set message = message if message is defined else "" %} {% set type = type if type is defined else "info" %} {% set dismissible = dismissible if dismissible is defined else true %} {% set extra_class = extra_class if extra_class is defined else "" %} @@ -9,7 +11,11 @@ x-data="{ visible: true }" x-show="visible" > - {{ message }} + {# Body content wins over the scalar `message` — a caller who wrote children + meant them. The two operands want opposite escaping and get it: JinjaX + wraps slot content in `Markup`, so it passes through this block + untouched, while `message` is caller-supplied text and is escaped. #} + {{ content if content|trim else message }} {% if dismissible %} {% endif %} -
{{ message }}
+ {# Body content wins over the scalar `message` — a caller who wrote children + meant them. The two operands want opposite escaping and get it: JinjaX + wraps slot content in `Markup`, so it passes through this block + untouched, while `message` is caller-supplied text and is escaped. #} +
{{ content if content|trim else message }}
{% endautoescape %} diff --git a/src/cf_ui/templates/jinja/foundation/Notification.jinja b/src/cf_ui/templates/jinja/foundation/Notification.jinja index 935101b..1ba11ef 100644 --- a/src/cf_ui/templates/jinja/foundation/Notification.jinja +++ b/src/cf_ui/templates/jinja/foundation/Notification.jinja @@ -1,5 +1,7 @@ -{#def message, type="info", dismissible=true, extra_class="" #} +{#def content="", message="", type="info", dismissible=true, extra_class="" #} {% autoescape true %} + {% set content = content if content is defined else "" %} + {% set message = message if message is defined else "" %} {% set type = type if type is defined else "info" %} {% set dismissible = dismissible if dismissible is defined else true %} {% set extra_class = extra_class if extra_class is defined else "" %} @@ -15,7 +17,18 @@ x-data="{ visible: true }" x-show="visible" > -

{{ message }}

+ {# Body content wins over the scalar `message` — a caller who wrote children + meant them. The two operands want opposite escaping and get it: JinjaX + wraps slot content in `Markup`, so it passes through this block + untouched, while `message` is caller-supplied text and is escaped. + + The `

` wraps only `message`. A body is arbitrary markup, and the HTML + parser closes an open `

` on encountering block content: + `

` parses to `

`, reparenting the + body onto `.callout` and leaving two empty paragraphs behind for + `.callout > :last-child` to match. `message` is text, so its wrapper + renders byte-identically to before. #} + {% if content|trim %}{{ content }}{% else %}

{{ message }}

{% endif %} {% if dismissible %}