Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Bringing component-based design to Django templates.
[Increase Re-usability with `{{ attrs }}`](#increase-re-usability-with--attrs-)
[Merging and Proxying Attributes with `:attrs`](#merging-and-proxying-attributes-with-attrs)
[In-component Variables with `<c-vars>`](#in-component-variables-with-c-vars)
[Push Stacks](#push-stacks)
[HTMX Example](#an-example-with-htmx)
[Limitations in Django that Cotton overcomes](#limitations-in-django-that-cotton-overcomes)
[Configuration](#configuration)
Expand Down Expand Up @@ -426,6 +427,54 @@ You can also provide a template expression, should the component be inside a sub

<hr>

### Push Stacks

Some components need to ship extra HTML (scripts, styles, Alpine data definitions, etc.) that belongs in a different slot in the template. Cotton's push/stack helpers let the component declare that markup once and render it later without duplication.

Enable the middleware when you want to use stacks:

```python
MIDDLEWARE = [
# ...
"django_cotton.middleware.CottonStackMiddleware",
]
```

Declare a stack in your base layout. The body is used as a fallback whenever nothing was pushed.

```html
<head>
<c-stack name="head">
<link rel="stylesheet" href="/static/base.css" />
</c-stack>
</head>
```

Inside a component use `<c-push>` to send markup to a named stack. Cotton deduplicates pushes automatically so the script below only renders once, no matter how many times the component is used.

```html
<!-- cotton/tooltip.html -->
<div class="tooltip">{{ slot }}</div>

<c-push to="head">
<script src="/static/libs/tooltip.js"></script>
</c-push>
```

Use the tooltip anywhere without worrying about double-including assets:

```html
<c-tooltip>Alpha</c-tooltip>
<c-tooltip>Beta</c-tooltip>
```

Optional attributes:

- `multiple` keeps every push instead of deduplicating by content.
- `key="unique-id"` deduplicates pushes using the given key rather than the rendered HTML.

<hr>

### An example with HTMX

Cotton helps carve out re-usable components, here we show how to make a re-usable form, reducing code repetition and improving maintainability:
Expand Down
41 changes: 41 additions & 0 deletions django_cotton/compiler_regex.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ def get_template_tag(self) -> str:
return "" # c-vars tags will be handled separately
elif self.tag_name == "c-slot":
return self._process_slot()
elif self.tag_name == "c-stack":
return self._process_stack()
elif self.tag_name == "c-push":
return self._process_push()
elif self.tag_name.startswith("c-"):
return self._process_component()
else:
Expand All @@ -48,6 +52,26 @@ def _process_component(self) -> str:
return f"{opening_tag}{extracted_attrs}{{% endc %}}"
return f"{opening_tag}{extracted_attrs}"

def _process_stack(self) -> str:
if self.is_closing:
return "{% endstack %}"

processed_attrs = self._process_simple_attributes()
opening_tag = f"{{% stack{processed_attrs} %}}"
if self.is_self_closing:
return f"{opening_tag}{{% endstack %}}"
return opening_tag

def _process_push(self) -> str:
if self.is_closing:
return "{% endpush %}"

processed_attrs = self._process_simple_attributes()
opening_tag = f"{{% push{processed_attrs} %}}"
if self.is_self_closing:
return f"{opening_tag}{{% endpush %}}"
return opening_tag

def _process_attributes(self) -> Tuple[str, str]:
"""Move any complex attributes to the {% attr %} tag"""
processed_attrs = []
Expand All @@ -66,6 +90,23 @@ def _process_attributes(self) -> Tuple[str, str]:

return " " + " ".join(processed_attrs), "".join(extracted_attrs)

def _process_simple_attributes(self) -> str:
attrs = []

for match in self.attr_pattern.finditer(self.attrs):
key, quote, value, unquoted_value = match.groups()
if value is None and unquoted_value is None:
attrs.append(key)
elif value is not None:
attrs.append(f"{key}={quote}{value}{quote}")
else:
attrs.append(f"{key}={unquoted_value}")

if not attrs:
return ""

return " " + " ".join(attrs)


class CottonCompiler:
def __init__(self):
Expand Down
92 changes: 92 additions & 0 deletions django_cotton/middleware.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
from __future__ import annotations

from typing import Optional

from django.http import HttpResponse
from django.template.response import SimpleTemplateResponse


class CottonStackMiddleware:
"""Post-process rendered templates to resolve cotton push/stack placeholders."""

def __init__(self, get_response):
self.get_response = get_response

def __call__(self, request):
# Flag the request so template tags know middleware is active.
setattr(request, "_cotton_stack_middleware", True)
response = self.get_response(request)
return self._process_response(request, response)

def _process_response(self, request, response: HttpResponse):
if isinstance(response, SimpleTemplateResponse) and not response.is_rendered:
response.render()

return self._apply(response, request)

def _apply(self, response: HttpResponse, request) -> HttpResponse:
cotton_state = getattr(request, "_cotton_stack_state", None)

if not cotton_state:
return response

placeholders = cotton_state.get("stack_placeholders") or []
push_stacks = cotton_state.get("push_stacks") or {}

if not placeholders:
self._reset(cotton_state, request)
return response

if getattr(response, "streaming", False):
self._reset(cotton_state, request)
return response

content_type = response.get("Content-Type", "")
if "html" not in content_type and "xml" not in content_type:
self._reset(cotton_state, request)
return response

rendered = self._get_response_content(response)
if rendered is None:
self._reset(cotton_state, request)
return response

for placeholder in placeholders:
stack_name = placeholder["name"]
stack = push_stacks.get(stack_name, {})
items = stack.get("items", [])
replacement = "".join(items) if items else placeholder.get("fallback", "")
rendered = rendered.replace(placeholder["placeholder"], replacement, 1)

response.content = rendered

self._reset(cotton_state, request)

return response

@staticmethod
def _get_response_content(response: HttpResponse) -> Optional[str]:
if isinstance(response, SimpleTemplateResponse):
# SimpleTemplateResponse keeps rendered content accessible via property.
if response.is_rendered:
return response.rendered_content
content = response.content
if content is None:
return None
if isinstance(content, bytes):
charset = getattr(response, "charset", "utf-8") or "utf-8"
return content.decode(charset, errors="ignore")
return str(content)

@staticmethod
def _reset(cotton_state: dict, request) -> None:
push_stacks = cotton_state.get("push_stacks")
if isinstance(push_stacks, dict):
push_stacks.clear()

placeholders = cotton_state.get("stack_placeholders")
if isinstance(placeholders, list):
placeholders.clear()

if hasattr(request, "_cotton_stack_state"):
delattr(request, "_cotton_stack_state")
136 changes: 136 additions & 0 deletions django_cotton/templatetags/_stack.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import hashlib

from django.template import Library, Node, TemplateSyntaxError
from django.template.base import token_kwargs, FilterExpression
from django.utils.safestring import mark_safe

from django_cotton.utils import get_cotton_data

register = Library()


class CottonStackNode(Node):
def __init__(self, name_expr: FilterExpression, nodelist):
self.name_expr = name_expr
self.nodelist = nodelist

def render(self, context):
stack_name = str(self.name_expr.resolve(context))
cotton_data = get_cotton_data(context)
fallback = self.nodelist.render(context) if self.nodelist else ""

request = context.get("request")
middleware_active = bool(getattr(request, "_cotton_stack_middleware", False)) if request else False

if not middleware_active:
pushes = cotton_data.get("push_stacks", {}).get(stack_name, {})
rendered = "".join(pushes.get("items", [])) if pushes else ""
return mark_safe(rendered or fallback)

# Generate unique placeholder to prevent accidental replacement
placeholder_id = hashlib.md5(f"{id(self)}_{stack_name}".encode()).hexdigest()[:8]
placeholder = f"__COTTON_STACK_{placeholder_id}_{stack_name}__"

cotton_data.setdefault("stack_placeholders", []).append(
{
"placeholder": placeholder,
"name": stack_name,
"fallback": fallback,
}
)

return placeholder


class CottonPushNode(Node):
def __init__(self, target_expr: FilterExpression, nodelist, key_expr=None, allow_multiple=False):
self.target_expr = target_expr
self.key_expr = key_expr
self.allow_multiple = allow_multiple
self.nodelist = nodelist

def render(self, context):
stack_name = str(self.target_expr.resolve(context))
cotton_data = get_cotton_data(context)

rendered_content = self.nodelist.render(context)

stacks = cotton_data.setdefault("push_stacks", {})
stack = stacks.setdefault(stack_name, {"items": [], "keys": set()})

dedupe_key = None
if not self.allow_multiple:
if self.key_expr is not None:
dedupe_key = self.key_expr.resolve(context)
else:
dedupe_key = rendered_content.strip()

if dedupe_key in stack["keys"]:
return ""

stack["keys"].add(dedupe_key)

stack["items"].append(rendered_content)

return ""


def cotton_stack(parser, token):
bits = token.split_contents()[1:]
kwargs = token_kwargs(bits, parser, support_legacy=False)

if bits:
raise TemplateSyntaxError("Unknown arguments provided to c-stack tag")

if "name" not in kwargs:
raise TemplateSyntaxError("c-stack tag requires a 'name' attribute")

nodelist = parser.parse(("endstack",))
parser.delete_first_token()

name_expr = kwargs.pop("name")

if kwargs:
raise TemplateSyntaxError(f"Unknown keyword arguments for c-stack tag: {', '.join(kwargs.keys())}")

return CottonStackNode(name_expr, nodelist)


def cotton_push(parser, token):
bits = token.split_contents()[1:]
kwargs = token_kwargs(bits, parser, support_legacy=False)

# Separate flags from kwargs
flags = []
for bit in bits:
if "=" not in bit and bit not in kwargs.values():
flags.append(bit)

if "to" not in kwargs:
raise TemplateSyntaxError("c-push tag requires a 'to' attribute")

if "key" in kwargs and "id" in kwargs:
raise TemplateSyntaxError("c-push tag cannot have both 'key' and 'id' attributes")

allow_multiple = "multiple" in flags
if allow_multiple:
flags.remove("multiple")

key_expr = None
for key_name in ("key", "id"):
if key_name in kwargs:
key_expr = kwargs.pop(key_name)
break

if flags:
raise TemplateSyntaxError(f"Unknown arguments for c-push tag: {', '.join(sorted(flags))}")

nodelist = parser.parse(("endpush",))
parser.delete_first_token()

target_expr = kwargs.pop("to")

if kwargs:
raise TemplateSyntaxError(f"Unknown keyword arguments for c-push tag: {', '.join(kwargs.keys())}")

return CottonPushNode(target_expr, nodelist, key_expr=key_expr, allow_multiple=allow_multiple)
3 changes: 3 additions & 0 deletions django_cotton/templatetags/cotton.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@
from django_cotton.templatetags._vars import cotton_cvars
from django_cotton.templatetags._slot import cotton_slot
from django_cotton.templatetags._attr import cotton_attr
from django_cotton.templatetags._stack import cotton_stack, cotton_push

register = template.Library()
register.tag("c", cotton_component)
register.tag("slot", cotton_slot)
register.tag("vars", cotton_cvars)
register.tag("attr", cotton_attr)
register.tag("stack", cotton_stack)
register.tag("push", cotton_push)


@register.filter
Expand Down
Loading