merge confs

This commit is contained in:
Will Abbott 2025-03-10 18:10:23 +00:00
commit 37e49caba5
46 changed files with 965 additions and 288 deletions

View file

@ -10,6 +10,7 @@
Bringing component-based design to Django templates.
- Docs site + demos: <a href="https://django-cotton.com" target="_blank">django-cotton.com</a>
- <a href="https://discord.gg/4x8ntQwHMe" target="_blank">Discord community</a> (new)
## Contents
@ -27,11 +28,13 @@ Bringing component-based design to Django templates.
[In-component Variables with `<c-vars>`](#in-component-variables-with-c-vars)
[HTMX Example](#an-example-with-htmx)
[Limitations in Django that Cotton overcomes](#limitations-in-django-that-cotton-overcomes)
[Configuration](#configuration)
[Caching](#caching)
[Version support](#support)
[Version support](#version-support)
[Changelog](#changelog)
[Comparison with other packages](#comparison-with-other-packages)
<hr>
## Why Cotton?
@ -45,6 +48,8 @@ Cotton aims to overcome [certain limitations](#limitations-in-django-that-cotton
- **Encapsulates UI:** Keep layout, design and interaction in one file (especially when paired with Tailwind and Alpine.js)
- **Compliments HTMX:** Create smart components, reducing repetition and enhancing maintainability.
<hr>
## Install
```bash
@ -67,6 +72,8 @@ If you have previously specified a custom loader, you should perform [manual set
- Component filenames use snake_case: `my_component.html`
- Components are called using kebab-case prefixed by 'c-': `<c-my-component />`
<hr>
## Walkthrough
### Your first component
@ -86,7 +93,7 @@ If you have previously specified a custom loader, you should perform [manual set
Everything provided between the opening and closing tag is provided to the component as `{{ slot }}`. It can contain any content, HTML or Django template expression.
### Add attributes
### Adding attributes
```html
<!-- cotton/button.html -->
@ -129,7 +136,7 @@ Named slots are a powerful concept. They allow us to provide HTML to appear in o
</c-button>
```
Named slots can also contain any django native template logic:
Named slots can also contain any html or django template expression:
```html
<!-- in view -->
@ -250,7 +257,7 @@ This benefits a number of use-cases, for example if you have a select component
Django templates adhere quite strictly to the MVC model and does not permit a lot of data manipulation in views. Fair enough, but what if we want to handle data for the purpose of UI state only? Having presentation related variables defined in the back is overkill and can quickly lead to higher maintenance cost and loses encapsulation of the component. Cotton allows you define in-component variables for the following reasons:
#### 1. Using `<c-vars>` for default attributes
#### Using `<c-vars>` for default attributes
In this example we have a button component with a default "theme" but it can be overridden.
@ -286,7 +293,7 @@ Now we have a default theme for our button, but it is overridable:
</a>
```
#### 2. Using `<c-vars>` to govern `{{ attrs }}`
#### Using `<c-vars>` to govern `{{ attrs }}`
Using `{{ attrs }}` to pass all attributes from parent scope onto an element in the component, you'll sometimes want to provide additional properties to the component which are not intended to be an attributes. In this case you can declare them in `<c-vars />` and it will prevent it from being in `{{ attrs }}`
@ -341,6 +348,8 @@ You can also provide a template expression, should the component be inside a sub
{% endfor %}
```
<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:
@ -369,6 +378,8 @@ Cotton helps carve out re-usable components, here we show how to make a re-usabl
</c-form>
```
<hr>
## Limitations in Django that Cotton overcomes
Whilst you _can_ build frontends with Djangos native tags, there are a few things that hold us back when we want to apply modern practices:
@ -394,25 +405,25 @@ In addition, Cotton enables you to navigate around some of the limitations with
### HTML in attributes
❌ **Django native:**
```html
{% my_component header="<h1>Header</h1>" %}
{% my_header icon="<svg>...</svg>" %}
```
✅ **Cotton:**
```html
<c-my-component>
<c-slot name="header">
<h1>Header</h1>
<c-my-header>
<c-slot name="icon">
<svg>...</svg>
</c-slot>
</c-my-component>
</c-my-header>
```
### Template expressions in attributes
❌ **Django native:**
```html
{% my_component model="todos.{{ index }}.name" extra="{% get_extra %}" %}
{% bio name="{{ first_name }} {{ last_name }}" extra="{% get_extra %}" %}
```
✅ **Cotton:**
```html
<c-my-component model="todos.{{ index }}.name" extra="{% get_extra %} />
<c-bio name="{{ first_name }} {{ last_name }}" extra="{% get_extra %} />
```
### Pass simple python types
@ -420,11 +431,13 @@ In addition, Cotton enables you to navigate around some of the limitations with
```html
{% my_component default_options="['yes', 'no', 'maybe']" %}
{% my_component config="{'open': True}" %}
{% my_component enabled="True" %}
```
✅ **Cotton:**
```html
<c-my-component :default_options="['yes', 'no', 'maybe']" />
<c-my-component :config="{'open': True}" />
<c-my-component :enabled="True" />
(provides a List and Dict to component)
```
@ -456,24 +469,42 @@ In addition, Cotton enables you to navigate around some of the limitations with
<c-component is="subfolder1.subfolder2.{{ component_name }}" />
```
<hr>
## Configuration
`COTTON_DIR` (default: "cotton")
The directory where your components are stored.
`COTTON_BASE_DIR` (default: None)
If you use a project-level templates folder then you can set the path here. This is not needed if your project already has a `BASE_DIR` variable.
`COTTON_SNAKE_CASED_NAMES` (default: True)
Whether to search for component filenames in snake_case. If set to False, you can use kebab-cased / hyphenated filenames.
<hr>
## Caching
Cotton is optimal when used with Django's cached.Loader. If you use <a href="https://django-cotton.com/docs/quickstart">automatic configuration</a> then the cached loader will be automatically applied. This feature has room for improvement, some desirables are:
Cotton is optimal when used with Django's cached.Loader. If you use <a href="https://django-cotton.com/docs/quickstart">automatic configuration</a> then the cached loader will be automatically applied.
- Integration with a cache backend to survive runtime restarts / deployments.
- Cache warming
For full docs and demos, checkout <a href="https://django-cotton.com" target="_blank">django-cotton.com</a>
<hr>
## Version Support
- Python >= 3.8
- Django >4.2,<5.2
<hr>
## Changelog
[See releases](https://github.com/wrabit/django-cotton/releases)
<hr>
## Comparison with other packages
@ -497,5 +528,8 @@ For full docs and demos, checkout <a href="https://django-cotton.com" target="_b
**Notes:**
- Some features here can be resolved with 3rd party plugins, for example for expressions, you can use something like `django-expr` package. So the list focus on comparison of core feature of that library.
- This comparison was created due to multiple requests
- Some features here can be resolved with 3rd party plugins, for example for expressions, you can use something like `django-expr` package. So the list focuses on a comparison of core feature of that library.
<hr>
For full docs and demos, checkout <a href="https://django-cotton.com" target="_blank">django-cotton.com</a>

View file

@ -0,0 +1 @@
button!

View file

@ -14,6 +14,4 @@ def index_view(request):
)
urlpatterns = [
path("", index_view),
]
urlpatterns = [path("", index_view)]

View file

@ -85,7 +85,7 @@ def main():
configure_django()
runs = 5
iterations = 5000
iterations = 200
print(f"Running benchmarks with {runs} runs, {iterations} iterations each")

View file

@ -0,0 +1 @@
i'm sub in project root

View file

@ -0,0 +1 @@
I'm from project root templates!

View file

@ -121,13 +121,26 @@ class CottonCompiler:
return replacements
def process_c_vars(self, html: str) -> Tuple[str, str]:
"""Extract c-vars content and remove c-vars tags from the html"""
match = self.c_vars_pattern.search(html)
"""
Extract c-vars content and remove c-vars tags from the html.
Raises ValueError if more than one c-vars tag is found.
"""
# Find all matches of c-vars tags
matches = list(self.c_vars_pattern.finditer(html))
if len(matches) > 1:
raise ValueError(
"Multiple c-vars tags found in component template. Only one c-vars tag is allowed per template."
)
# Process single c-vars tag if present
match = matches[0] if matches else None
if match:
attrs = match.group(1)
vars_content = f"{{% vars {attrs.strip()} %}}"
html = self.c_vars_pattern.sub("", html) # Remove all c-vars tags
return vars_content, html
return "", html
def process(self, html: str) -> str:

View file

@ -2,6 +2,7 @@ import hashlib
import os
from functools import lru_cache
from django.conf import settings
from django.template.loaders.base import Loader as BaseLoader
from django.core.exceptions import SuspiciousFileOperation
from django.template import TemplateDoesNotExist, Origin
@ -50,14 +51,25 @@ class Loader(BaseLoader):
@lru_cache(maxsize=None)
def get_dirs(self):
"""This works like the file loader with APP_DIRS = True."""
"""Retrieves possible locations of cotton directory"""
dirs = list(self.dirs or self.engine.dirs)
# Include any included installed app directories, e.g. project/app1/templates
for app_config in apps.get_app_configs():
template_dir = os.path.join(app_config.path, "templates")
if os.path.isdir(template_dir):
dirs.append(template_dir)
# Check project root templates, e.g. project/templates
base_dir = getattr(settings, "COTTON_BASE_DIR", None)
if base_dir is None:
base_dir = getattr(settings, "BASE_DIR", None)
if base_dir is not None:
root_template_dir = os.path.join(base_dir, "templates")
if os.path.isdir(root_template_dir):
dirs.append(root_template_dir)
return dirs
def reset(self):

View file

@ -2,7 +2,7 @@ import functools
from typing import Union
from django.conf import settings
from django.template import Library
from django.template import Library, TemplateDoesNotExist
from django.template.base import (
Node,
)
@ -40,6 +40,10 @@ class CottonComponentNode(Node):
value = self._strip_quotes_safely(value)
if value is True: # Boolean attribute
component_data["attrs"][key] = True
elif key.startswith("::"): # Escaping 1 colon e.g for shorthand alpine
key = key[1:]
# component_data["slots"][key] = value
component_data["attrs"][key] = value
elif key.startswith(":"):
key = key[1:]
try:
@ -86,13 +90,30 @@ class CottonComponentNode(Node):
template_path = self._generate_component_template_path(self.component_name, attrs.get("is"))
if template_path not in cache:
if template_path in cache:
return cache[template_path]
# Try to get the primary template
try:
template = get_template(template_path)
if hasattr(template, "template"):
template = template.template
cache[template_path] = template
return template
except TemplateDoesNotExist:
# If the primary template doesn't exist, try the fallback path (index.html)
fallback_path = template_path.rsplit(".html", 1)[0] + "/index.html"
return cache[template_path]
# Check if the fallback template is already cached
if fallback_path in cache:
return cache[fallback_path]
# Try to get the fallback template
template = get_template(fallback_path)
if hasattr(template, "template"):
template = template.template
cache[fallback_path] = template
return template
def _create_isolated_context(self, original_context, component_state):
# Get the request object from the original context
@ -131,7 +152,13 @@ class CottonComponentNode(Node):
)
component_name = is_
component_tpl_path = component_name.replace(".", "/").replace("-", "_")
component_tpl_path = component_name.replace(".", "/")
# Cotton by default will look for snake_case version of comp names. This can be configured to allow hyphenated names.
snaked_cased_named = getattr(settings, "COTTON_SNAKE_CASED_NAMES", True)
if snaked_cased_named:
component_tpl_path = component_tpl_path.replace("-", "_")
cotton_dir = getattr(settings, "COTTON_DIR", "cotton")
return f"{cotton_dir}/{component_tpl_path}.html"
@ -143,25 +170,60 @@ class CottonComponentNode(Node):
def cotton_component(parser, token):
"""
Parse a cotton component tag and return a CottonComponentNode.
It accepts spaces inside quoted attributes for example if we want to pass valid json that contains spaces in values.
@TODO Add support here for 'complex' attributes so we can eventually remove the need for the 'attr' tag. The idea
here is to render `{{` and `{%` blocks in tags.
"""
bits = token.split_contents()[1:]
component_name = bits[0]
attrs = {}
only = False
node_class = CottonComponentNode
current_key = None
current_value = []
for bit in bits[1:]:
if bit == "only":
# if we see `only` we isolate context
only = True
continue
try:
key, value = bit.split("=")
attrs[key] = value
except ValueError:
attrs[bit] = True
if "=" in bit:
# If we were building a previous value, store it
if current_key:
attrs[current_key] = " ".join(current_value)
current_value = []
# Start new key-value pair
key, value = bit.split("=", 1)
if value.startswith(("'", '"')):
if value.endswith(("'", '"')) and value[0] == value[-1]:
# Complete quoted value
attrs[key] = value
else:
# Start of quoted value
current_key = key
current_value = [value]
else:
# Simple unquoted value
attrs[key] = value
else:
if current_key:
# Continue building quoted value
current_value.append(bit)
else:
# Boolean attribute
attrs[bit] = True
# Store any final value being built
if current_key:
attrs[current_key] = " ".join(current_value)
nodelist = parser.parse(("endc",))
parser.delete_first_token()
return node_class(component_name, nodelist, attrs, only)
return CottonComponentNode(component_name, nodelist, attrs, only)

View file

@ -35,11 +35,7 @@ class CottonVarsNode(Node):
except UnprocessableDynamicAttr:
pass
else:
try:
resolved_value = Variable(value).resolve(context)
except (VariableDoesNotExist, IndexError):
resolved_value = value
attrs[key] = resolved_value
attrs[key] = value
attrs.exclude_from_string_output(key)
# Process cvars without values

View file

@ -483,3 +483,52 @@ class AttributeHandlingTests(CottonTestCase):
self.assertTrue(
"in default slot: <strong {% if 1 == 2 %}hidden{% endif %}></strong>" in compiled
)
def test_colon_escaping(self):
self.create_template(
"cotton/colon_escaping.html",
"""
attrs: '{{ attrs }}'
""",
)
self.create_template(
"colon_escaping_view.html",
"""
<c-colon-escaping
::string="variable"
:dynamic="variable"
::complex-string="{'something': 1}"
:complex-dynamic="{'something': 1}"/>
""",
"view/",
context={"variable": "Ive been resolved!"},
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(
response,
"""attrs: ':string="variable" dynamic="Ive been resolved!" :complex-string="{'something': 1}" complex-dynamic="{'something': 1}"'""",
)
def test_attributes_can_contain_valid_json(self):
self.create_template(
"cotton/json_attrs.html",
"""
<button {{ attrs }}>{{ slot }}</button>
""",
)
self.create_template(
"json_attrs_view.html",
"""
<c-json-attrs data-something='{"key=dd": "the value with= spaces"}' />
""",
"view/",
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, """{"key=dd": "the value with= spaces"}""")

View file

@ -1,104 +0,0 @@
from django_cotton.tests.utils import CottonTestCase
class BasicComponentTests(CottonTestCase):
def test_component_is_rendered(self):
self.create_template(
"cotton/render.html",
"""<div class="i-am-component">{{ slot }}</div>""",
)
self.create_template(
"view.html",
"""<c-render>Hello, World!</c-render>""",
"view/",
)
# Override URLconf
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-component">')
self.assertContains(response, "Hello, World!")
def test_nested_rendering(self):
self.create_template(
"cotton/parent.html",
"""
<div class="i-am-parent">
{{ slot }}
</div>
""",
)
self.create_template(
"cotton/child.html",
"""
<div class="i-am-child"></div>
""",
)
self.create_template(
"cotton/nested_render_view.html",
"""
<c-parent>
<c-child>d</c-child>
</c-parent>
""",
"view/",
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-parent">')
self.assertContains(response, '<div class="i-am-child">')
def test_cotton_directory_can_be_configured(self):
custom_dir = "components"
self.create_template(
f"{custom_dir}/custom_directory.html",
"""<div class="i-am-component">{{ slot }}</div>""",
)
self.create_template(
"custom_directory_view.html",
"""<c-custom-directory>Hello, World!</c-custom-directory>""",
"view/",
)
# Override URLconf
with self.settings(ROOT_URLCONF=self.url_conf(), COTTON_DIR=custom_dir):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-component">')
self.assertContains(response, "Hello, World!")
def test_self_closing_is_rendered(self):
self.create_template("cotton/self_closing.html", """I self closed!""")
self.create_template(
"self_closing_view.html",
"""
1: <c-self-closing/>
2: <c-self-closing />
3: <c-self-closing />
""",
"view/",
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, "1: I self closed!")
self.assertContains(response, "2: I self closed!")
self.assertContains(response, "3: I self closed!")
def test_loader_scans_all_app_directories(self):
self.create_template(
"app_outside_of_dirs_view.html", """<c-app-outside-of-dirs />""", "view/"
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(
response,
"""My template path was not specified in settings!""",
)

View file

@ -1,3 +1,4 @@
from django_cotton.tests.utils import CottonTestCase, get_compiled
@ -70,3 +71,29 @@ class CompileTests(CottonTestCase):
"{% cotton_verbatim %}" not in compiled,
"Compilation should not leave {% cotton_verbatim %} tags in the output",
)
def test_raises_error_on_duplicate_cvars(self):
with self.assertRaises(ValueError) as cm:
get_compiled(
"""
<c-vars />
<c-vars />
"""
)
self.assertEqual(
str(cm.exception),
"Multiple c-vars tags found in component template. Only one c-vars tag is allowed per template.",
)
def test_raises_on_slots_without_name(self):
with self.assertRaises(ValueError) as cm:
get_compiled(
"""
<c-slot />
"""
)
self.assertTrue(
"c-slot tag must have a name attribute:" in str(cm.exception),
)

View file

@ -75,7 +75,7 @@ class ContextIsolationTests(CottonTestCase):
self.assertContains(response, "From view context scope: ''")
self.assertContains(response, "Direct attribute: 'yes'")
@override_settings(COTTON_CONTEXT_ISOLATION_ENABLED=False)
@override_settings(COTTON_ENABLE_CONTEXT_ISOLATION=False)
def test_legacy_context_behaviour(self):
"""Test components do not have isolated context"""
self.create_template(

View file

@ -380,3 +380,39 @@ class CvarTests(CottonTestCase):
rendered = render_to_string("cotton/direct_render.html")
self.assertTrue("I'm all set" in rendered)
def test_cvars_template_basic_types_parsing(self):
self.create_template(
"cotton/cvars_template_basic_types.html",
"""
<c-vars
none="None"
number="1"
boolean_true="True"
boolean_false="False" />
{% if none == "None" %}
I am string None
{% endif %}
{% if number == "1" %}
I am string 1
{% endif %}
{% if boolean_true == "True" %}
I am string True
{% endif %}
{% if boolean_false == "False" %}
I am string False
{% endif %}
""",
)
rendered = render_to_string("cotton/cvars_template_basic_types.html")
self.assertTrue("I am string None" in rendered)
self.assertTrue("I am string 1" in rendered)
self.assertTrue("I am string True" in rendered)
self.assertTrue("I am string False" in rendered)

View file

@ -0,0 +1,173 @@
from django.test import override_settings
from django_cotton.tests.utils import CottonTestCase, get_rendered
class MiscComponentTests(CottonTestCase):
def test_cotton_directory_can_be_configured(self):
custom_dir = "components"
self.create_template(
f"{custom_dir}/custom_directory.html",
"""<div class="i-am-component">{{ slot }}</div>""",
)
self.create_template(
"custom_directory_view.html",
"""<c-custom-directory>Hello, World!</c-custom-directory>""",
"view/",
)
# Override URLconf
with self.settings(ROOT_URLCONF=self.url_conf(), COTTON_DIR=custom_dir):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-component">')
self.assertContains(response, "Hello, World!")
def test_loader_scans_all_app_directories(self):
self.create_template(
"app_outside_of_dirs_view.html", """<c-app-outside-of-dirs />""", "view/"
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(
response,
"""My template path was not specified in settings!""",
)
def test_context_isolation_by_default(self):
"""Context should be isolated by default, but still include context from custom and built in processors."""
pass
def test_only_gives_isolated_context(self):
self.create_template(
"cotton/only.html",
"""
<a class="{{ class|default:"donttouch" }}">test</a>
""",
)
self.create_template(
"no_only_view.html",
"""
<c-only />
""",
"no_only_view/",
context={"class": "herebedragons"}, # this should pass to `class` successfully
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/no_only_view/")
self.assertNotContains(response, "donttouch")
self.assertContains(response, "herebedragons")
self.create_template(
"only_view.html",
"""
<c-only only />
""",
"only_view/",
context={
"class": "herebedragons"
}, # this should not pass to `class` due to `only` being present
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/only_view/")
self.assertNotContains(response, "herebedragons")
self.assertContains(response, "donttouch")
self.create_template(
"only_view2.html",
"""
<c-only class="october" only />
""",
"only_view2/",
context={
"class": "herebedragons"
}, # this should not pass to `class` due to `only` being present
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/only_view2/")
self.assertNotContains(response, "herebedragons")
self.assertNotContains(response, "donttouch")
self.assertContains(response, "october")
def test_only_with_dynamic_components(self):
self.create_template(
"cotton/dynamic_only.html",
"""
From parent comp scope: '{{ class }}'
From view context scope: '{{ view_item }}'
Direct attribute: '{{ direct }}'
""",
)
self.create_template(
"cotton/middle_component.html",
"""
<c-component is="{{ comp }}" only direct="yes" />
""",
)
self.create_template(
"dynamic_only_view.html",
"""<c-middle-component class="mb-5" />""",
"view/",
context={"comp": "dynamic_only", "view_item": "blee"},
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, "From parent comp scope: ''")
self.assertContains(response, "From view context scope: ''")
self.assertContains(response, "Direct attribute: 'yes'")
@override_settings(COTTON_SNAKE_CASED_NAMES=False)
def test_hyphen_naming_convention(self):
self.create_template(
"cotton/some-subfolder/hyphen-naming-convention.html",
"I have a hyphenated component name",
)
html = """
<c-some-subfolder.hyphen-naming-convention />
"""
rendered = get_rendered(html)
self.assertTrue("I have a hyphenated component name" in rendered)
def test_multiple_app_subdirectory_access(self):
self.create_template(
"cotton/app_dir.html",
"I'm from app templates!",
)
html = """
<c-app-dir />
<c-project-root />
<c-app2.sub />
"""
rendered = get_rendered(html)
self.assertTrue("I'm from app templates!" in rendered)
self.assertTrue("I'm from project root templates!" in rendered)
self.assertTrue("i'm sub in project root" in rendered)
def test_index_file_access(self):
self.create_template(
"cotton/accordion/index.html",
"I'm an index file!",
)
html = """
<c-accordion />
"""
rendered = get_rendered(html)
self.assertTrue("I'm an index file!" in rendered)

View file

@ -3,6 +3,74 @@ from django_cotton.tests.utils import get_compiled
class TemplateRenderingTests(CottonTestCase):
def test_component_is_rendered(self):
self.create_template(
"cotton/render.html",
"""<div class="i-am-component">{{ slot }}</div>""",
)
self.create_template(
"view.html",
"""<c-render>Hello, World!</c-render>""",
"view/",
)
# Override URLconf
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-component">')
self.assertContains(response, "Hello, World!")
def test_nested_rendering(self):
self.create_template(
"cotton/parent.html",
"""
<div class="i-am-parent">
{{ slot }}
</div>
""",
)
self.create_template(
"cotton/child.html",
"""
<div class="i-am-child"></div>
""",
)
self.create_template(
"cotton/nested_render_view.html",
"""
<c-parent>
<c-child>d</c-child>
</c-parent>
""",
"view/",
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, '<div class="i-am-parent">')
self.assertContains(response, '<div class="i-am-child">')
def test_self_closing_is_rendered(self):
self.create_template("cotton/self_closing.html", """I self closed!""")
self.create_template(
"self_closing_view.html",
"""
1: <c-self-closing/>
2: <c-self-closing />
3: <c-self-closing />
""",
"view/",
)
with self.settings(ROOT_URLCONF=self.url_conf()):
response = self.client.get("/view/")
self.assertContains(response, "1: I self closed!")
self.assertContains(response, "2: I self closed!")
self.assertContains(response, "3: I self closed!")
def test_new_lines_in_attributes_are_preserved(self):
self.create_template(
"cotton/preserved.html",
@ -142,7 +210,7 @@ class TemplateRenderingTests(CottonTestCase):
def test_cvars_isnt_changing_global_context(self):
self.create_template(
"cotton/child.html",
"cotton/cvars_child.html",
"""
<c-vars />
@ -150,7 +218,7 @@ class TemplateRenderingTests(CottonTestCase):
""",
)
self.create_template(
"cotton/parent.html",
"cotton/cvars_parent.html",
"""
name: parent (class: {{ class }}))
@ -161,9 +229,9 @@ class TemplateRenderingTests(CottonTestCase):
self.create_template(
"slot_scope_view.html",
"""
<c-parent>
<c-child class="testy" />
</c-parent>
<c-cvars-parent>
<c-cvars-child class="testy" />
</c-cvars-parent>
""",
"view/",
)

View file

@ -70,6 +70,12 @@ class CottonTestCase(TestCase):
def create_template(self, name, content, url=None, context={}):
"""Create a template file in the temporary directory and return the path"""
# To test the non-default of allowing non-snake-cased names
snake_cased_names = getattr(settings, "COTTON_SNAKE_CASED_NAMES", True)
if not snake_cased_names:
name = name.replace("_", "-")
path = os.path.join(self.temp_dir, name)
if os.path.exists(path):

View file

@ -9,7 +9,7 @@ COPY . .
RUN ["npx", "tailwindcss", "-o", "./docs_project/static/app.css"]
# Use an official Python runtime as a base image
FROM python:3.12-slim-bookworm as base
FROM python:3.12-slim-bookworm AS base
# Setup env
ENV PIP_DISABLE_PIP_VERSION_CHECK=on \
@ -34,4 +34,7 @@ RUN poetry config virtualenvs.create false \
RUN SECRET_KEY=dummy STATIC_URL='/staticfiles/' python manage.py collectstatic --noinput --verbosity 2
# Now uninstall with poetry the container version of django-cotton, leaving the local version when we're developing
RUN poetry remove django-cotton
CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ]

View file

@ -14,6 +14,10 @@
<h1 id="tabs">Re-usable tabs with alpine.js</h1>
<c-note>
Be sure to checkout the notes on alpine.js support <a href="{% url 'components' %}#alpine-js-support">here</a>.
</c-note>
<p>Let's tackle together a common UI requirement - tabs.</p>
<h3 class="mt-0 pt-2 mb-5" id="goals">We'll start by defining some goals:</h3>
@ -69,7 +73,7 @@
<p>Now we have the design right, let's chop it up into components.</p>
<h4>Tabs component</h4>
<h3>{{ '<c-tabs />'|force_escape }} component</h3>
<c-snippet label="cotton/tabs.html">{% cotton_verbatim %}{% verbatim %}
<div class="bg-white rounded-lg overflow-hidden border shadow">
@ -85,7 +89,7 @@
{% endcotton_verbatim %}{% endverbatim %}
</c-snippet>
<h4>Tab component</h4>
<h3>{{ '<c-tab />'|force_escape }} component</h3>
<c-snippet label="cotton/tab.html">{% cotton_verbatim %}{% verbatim %}
<div>
@ -194,13 +198,13 @@
</c-snippet>
<c-navigation>
<c-slot name="prev">
<a href="{% url 'form-fields' %}">Form Inputs</a>
</c-slot>
<c-slot name="next">
<a href="{% url 'layouts' %}">Layouts</a>
</c-slot>
</c-navigation>
<c-navigation>
<c-slot name="prev">
<a href="{% url 'form-fields' %}">Form Inputs</a>
</c-slot>
<c-slot name="next">
<a href="{% url 'layouts' %}">Layouts</a>
</c-slot>
</c-navigation>
</c-layouts.with-sidebar>

View file

@ -11,6 +11,7 @@
<c-index-sublink><a href="#boolean-attributes" class="no-underline">Boolean Attributes</a></c-index-sublink>
<c-index-sublink><a href="#dynamic-components" class="no-underline">Dynamic Components</a></c-index-sublink>
<c-index-sublink><a href="#context-isolation" class="no-underline">Context Isolation</a></c-index-sublink>
<c-index-sublink><a href="#alpine-js-support" class="no-underline">Alpine.js Support</a></c-index-sublink>
<c-index-sublink><a href="#summary" class="no-underline">Summary of concepts</a></c-index-sublink>
</c-slot>
@ -19,7 +20,7 @@
<h2>1. The basic building block: {% verbatim %}{{ slot }}{% endverbatim %}</h2>
<p>The <c-highlight>{% verbatim %}{{ slot }}{% endverbatim %}</c-highlight> variable captures all content passed between a component's opening and closing tags.</p>
<p>The <code>{% verbatim %}{{ slot }}{% endverbatim %}</code> variable captures all content passed between a component's opening and closing tags.</p>
<c-snippet label="cotton/box.html">{% cotton_verbatim %}{% verbatim %}
<div class="box">
@ -39,9 +40,9 @@
</c-slot>
</c-snippet>
<c-hr />
<c-hr id="attributes" />
<h2 id="attributes">2. Adding Component Attributes</h2>
<h2>2. Adding Component Attributes</h2>
<p>We can further customize components with attribute, which allow you to pass specific data into the component as key-value pairs.</p>
@ -58,11 +59,11 @@
</c-slot>
</c-snippet>
<c-hr />
<c-hr id="named-slots" />
<h2 id="named-slots">3. Using Named Slots</h2>
<h2>3. Using Named Slots</h2>
<p>If we want to pass HTML instead of just a string (or another data type) into a component, we can pass them as <c-highlight>named slots</c-highlight> with the <c-highlight>{{ '<c-slot name="...">...</c-slot>'|force_escape }}</c-highlight> syntax.</p>
<p>If we want to pass HTML instead of just a string (or another data type) into a component, we can pass them as <c-highlight>named slots</c-highlight> with the <code>{{ '<c-slot name="...">...</c-slot>'|force_escape }}</code> syntax.</p>
<p>So as with normal attributes, you reference the slot content like normal variables, as in:</p>
@ -93,9 +94,13 @@
</c-slot>
</c-snippet>
<c-hr />
<c-note>
Component filenames should be <c-highlight>snake_cased</c-highlight> by default. To use <c-highlight>kebab-cased</c-highlight> / hyphenated filenames instead, set <code>COTTON_SNAKE_CASED_NAMES</code> to <code>False</code> in your settings.py, <a href="{% url 'configuration' %}">more</a>.
</c-note>
<h2 id="dynamic-attributes">4. Dynamic Attributes with ":"</h2>
<c-hr id="dynamic-attributes" />
<h2>4. Dynamic Attributes with ":"</h2>
<p>We saw how by default, all attributes that we pass to a component are treated as strings. If we want to pass HTML, we can use named slots. But what if we want to pass another data type like a template variable, boolean, integer, float, dictionary, list, dictionary?</p>
@ -176,9 +181,9 @@ context = { 'today': Weather.objects.get(...) }
{% endcotton_verbatim %}{% endverbatim %}
</c-snippet>
<c-hr />
<c-hr id="attrs" />
<h2 id="attrs">5. Pass all attributes with {% verbatim %}{{ attrs }}{% endverbatim %}</h2>
<h2>5. Pass all attributes with {% verbatim %}{{ attrs }}{% endverbatim %}</h2>
<p>Sometimes it's useful to be able to reflect all attributes provided in the parent on to an HTML element in the component. This is particularly powerful when you are building <a href="{% url 'form-fields' %}">form inputs</a>.</p>
@ -204,17 +209,17 @@ context = { 'today': Weather.objects.get(...) }
{% endcotton_verbatim %}{% endverbatim %}
</c-snippet>
<c-hr />
<c-hr id="vars" />
<h2 id="vars">6. <c-highlight>{{ '<c-vars />'|force_escape }}</c-highlight>: Defining Local Variables</h2>
<h2>6. Defining Local Variables with <code>{{ '<c-vars />'|force_escape }}</code></h2>
<p>{{ '<c-vars />'|force_escape }} gives components local state and default behavior, making them more self-sufficient and reducing the need for repetitive attribute declarations and maintaining UI state in the backend.</p>
<p>The <code>{{ '<c-vars />'|force_escape }}</code> tag simplifies component design by allowing local variable definition, reducing the need for repetitive attribute declarations and maintaining backend state.</p>
<p>Vars are defined using a <c-highlight>{{ '<c-vars />'|force_escape }}</c-highlight> tag at the top of a component file. It can either contain key="value" pairs or just standalone keys (keep reading to understand why).</p>
<p>Place a single <code>{{ '<c-vars />'|force_escape }}</code> at the top of a component to set key-value pairs that provide a default configuration.</p>
<h3 id="default-attributes">Use {{ '<c-vars />'|force_escape }} to set attribute defaults</h3>
<h3>Example: Setting Default Attributes</h3>
<p>You may design a component that will often have a default behaviour and rarely needs overriding. In this case, you may opt to give a default value to your component.</p>
<p>In components with common defaults, <code>{{ '<c-vars />'|force_escape }}</code> can pre-define attributes that rarely need overriding.</p>
<c-snippet label="cotton/alert.html">{% cotton_verbatim %}{% verbatim %}
<c-vars type="success" />
@ -235,9 +240,9 @@ context = { 'today': Weather.objects.get(...) }
</c-slot>
</c-snippet>
<h3 id="excluded">{{ '<c-vars />'|force_escape }} are excluded from {% verbatim %}{{ attrs }}{% endverbatim %}</h3>
<h3>{{ '<c-vars />'|force_escape }} are excluded from {% verbatim %}{{ attrs }}{% endverbatim %}</h3>
<p>Keys defined in {{ '<c-vars />'|force_escape }} will not be included in {% verbatim %}{{ attrs }}{% endverbatim %}. This is useful when some of the properties you pass down to a component are for configuration purposes only and not intended as attributes.</p>
<p>Keys in <code>{{ '<c-vars />'|force_escape }}</code> are omitted from <code>{% verbatim %}{{ attrs }}{% endverbatim %}</code>, making them ideal for configuration attributes that shouldn't appear in HTML attributes.</p>
<c-snippet label="cotton/input_group.html">{% cotton_verbatim %}{% verbatim %}
<c-vars label errors />
@ -271,12 +276,11 @@ context = { 'today': Weather.objects.get(...) }
</c-slot>
</c-snippet>
<c-note>Specifying an attribute as a 'var' will remove the item from {% verbatim %}{{ attrs }}{% endverbatim %}. It can also be a single key without a default value, this is when you know a particular attribute should not end up in {% verbatim %}{{ attrs }}{% endverbatim %}, whether it's defined in a parent or not.</c-note>
<p>By specifying <code>label</code> and <code>errors</code> keys in <code>{{ '<c-vars />'|force_escape }}</code>, these attributes wont be included in <code>{% verbatim %}{{ attrs }}{% endverbatim %}</code>, allowing you to control attributes that are designed for component configuration and those intended as attributes.</p>
<c-hr id="boolean-attributes" />
<c-hr />
<h2 id="boolean-attributes">7. Boolean attributes</h2>
<h2>7. Boolean attributes</h2>
<p>Sometimes you just want to pass a simple boolean to a component. Cotton supports providing the attribute name without a value which will provide a boolean True to the component.</p>
@ -293,15 +297,15 @@ context = { 'today': Weather.objects.get(...) }
<c-input name="telephone" required />
{% endcotton_verbatim %}{% endverbatim %}
<c-slot name="preview">
<input type="text" autocomplete="off" class="border px-2 py-1 shadow rounded" name="telephone" placeholder="Telephone" /> <span class="text-red-500 text-3xl">*</span>
<input type="text" autocomplete="off" class="border px-2 py-1 shadow rounded" name="telephone" placeholder="Telephone" /> <span class="text-red-500 text-2xl">*</span>
</c-slot>
</c-snippet>
<c-hr />
<c-hr id="dynamic-components" />
<h2 id="dynamic-components">8. Dynamic Components</h2>
<h2>8. Dynamic Components</h2>
<p>There can be times where components need to be included dynamically. For these cases we can reach for a special <c-highlight>{{ '<c-component>'|force_escape }}</c-highlight> tag with an <c-highlight>is</c-highlight> attribute:</p>
<p>There can be times where components need to be included dynamically. For these cases we can reach for a special <code>{{ '<c-component>'|force_escape }}</code> tag with an <code>is</code> attribute:</p>
<c-snippet label="cotton/icon_list.html">{% cotton_verbatim %}{% verbatim %}
{% for icon in icons %}
@ -309,7 +313,7 @@ context = { 'today': Weather.objects.get(...) }
{% endfor %}
{% endcotton_verbatim %}{% endverbatim %}</c-snippet>
<p>The <c-highlight>is</c-highlight> attribute is similar to other attributes so we have a number of possibilities to define it:</p>
<p>The <code>is</code> attribute is similar to other attributes so we have a number of possibilities to define it:</p>
<c-snippet label="cotton/icon_list.html">{% cotton_verbatim %}{% verbatim %}
<!-- as variable -->
@ -319,12 +323,12 @@ context = { 'today': Weather.objects.get(...) }
<c-component is="icon_{{ icon_name }}" />
{% endcotton_verbatim %}{% endverbatim %}</c-snippet>
<c-hr />
<c-hr id="context-isolation" />
<h2 id="context-isolation">9. Context Isolation</h2>
<h2>9. Context Isolation</h2>
<p>Cotton is inspired by patterns found in frontend frameworks like React, Vue and Svelte. When working with these
patterns, state is not typically shared between components. This ensures data from other components do not 'leak' into
patterns, state is not typically shared between components. This ensures data from other components does not 'leak' into
others which can cause side effects that are difficult to trace.</p>
<p>Therefore, each component's context only contains, by default:</p>
@ -339,22 +343,34 @@ context = { 'today': Weather.objects.get(...) }
<p>You can pass the <c-highlight>only</c-highlight> attribute to the component, which will prevent it from adopting any context (incl. context processors) other than it's direct attributes.</p>
<h3>Retain legacy behaviour</h3>
<c-hr id="alpine-js-support" />
<p>Since context isolation is enabled by default since v2.0.0, there may be cases you wish to keep the legacy behaviour, for that reason, you can pass a `COTTON_CONTEXT_ISOLATION_ENABLED = False` in your settings.py. This is not recommend due to the aforementioned reason so allowing context isolation by default is encouraged.</p>
<h2>10. Alpine.js support</h2>
<c-hr />
<p>The following key features allow you to build re-usable components with alpine.js:</p>
<h2 id="summary">Summary of Concepts</h2>
<c-ul>
<li>
<code>x-data</code> is accessible as <code>{% verbatim %}{{ x_data }}{% endverbatim %}</code> inside the component as cotton makes available snake_case versions of all kebab-cased attributes. (If you use <code>{% verbatim %}{{ attrs }}{% endverbatim %}</code> then the output will already be in the correct case).
</li>
<li><a href="https://alpinejs.dev/directives/bind" target="_blank">Shorthand x-bind</a> support (<code>:example</code>). Because single <code>:</code> attribute prefixing is reserved for cotton's <a href="{% url 'components' %}#dynamic-attributes">dynamic attributes</a>,
we can escape the first colon using <code>::</code>. This will ensure the attribute maintains a single <code>:</code> inside <code>{% verbatim %}{{ attrs }}{% endverbatim %}</code>
</li>
</c-ul>
<c-hr id="summary" />
<h2>Summary of Concepts</h2>
<ul>
<li><c-highlight>{% verbatim %}{{ slot }}{% endverbatim %}</c-highlight> - Default content injection.</li>
<li><code>{% verbatim %}{{ slot }}{% endverbatim %}</code> - Default content injection.</li>
<li><c-highlight>Attributes</c-highlight> - Simple, straightforward customization.</li>
<li><c-highlight>Named Slots</c-highlight> - Provide HTML or template partial as a variable in the component.</li>
<li><c-highlight>`:` Dynamic Attributes</c-highlight> - Pass variables and other data types other than strings.</li>
<li><c-highlight>{% verbatim %}{{ attrs }}{% endverbatim %}</c-highlight> - Prints attributes as HTML attributes.</li>
<li><c-highlight>{{ '<c-vars />'|force_escape }}</c-highlight> - Set default values and other component state.</li>
<li><c-highlight>Boolean attributes</c-highlight> - Attributes without values are passed down as `:bool = True`</li>
<li><c-highlight>{{ '<c-component is=".." />'|force_escape }}</c-highlight> - Dynamically insert a component where the name is generated by a variable or template expression</li>
<li><c-highlight><code>:</code> Dynamic Attributes</c-highlight> - Pass variables and other data types other than strings.</li>
<li><code>{% verbatim %}{{ attrs }}{% endverbatim %}</code> - Prints attributes as HTML attributes.</li>
<li><code>{{ '<c-vars />'|force_escape }}</code> - Set default values and other component state.</li>
<li><c-highlight>Boolean attributes</c-highlight> - Attributes without values are passed down as <code>True</code></li>
<li><c-highlight>Dynamic Components</c-highlight> - Insert a component where the name is generated by a variable or template expression: <code>{{ '<c-component :is="my_variable" />'|force_escape }}</code>
</li>
</ul>
@ -363,7 +379,7 @@ context = { 'today': Weather.objects.get(...) }
<a href="{% url 'quickstart' %}">Quickstart</a>
</c-slot>
<c-slot name="next">
<a href="{% url 'layouts' %}">Layouts</a>
<a href="{% url 'usage-patterns' %}">Usage Patterns</a>
</c-slot>
</c-navigation>

View file

@ -3,15 +3,66 @@
<p>The following configuration can be provided in your `settings.py`:</p>
<h4>COTTON_DIR</h4>
<p>str (default: 'cotton')</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<code>COTTON_DIR</code>
<div>str (default: 'cotton')</div>
</div>
<div>
Change the default path in your templates directory where cotton components can be placed, for example "components".
</div>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<code>COTTON_ENABLE_CONTEXT_ISOLATION</code>
<div>boolean (default: True)</div>
</div>
<div>
Set to `False` to allow global context to be available through all components. (see <a href="{% url 'components' %}#context-isolation">context isolation</a> for more information.
</div>
</div>
<c-hr />
<h4>COTTON_CONTEXT_ISOLATION_ENABLED</h4>
<p>str (default: 'True')</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<code>COTTON_BASE_DIR</code>
<div>str (default: None)</div>
</div>
<div>
The base directory where - in addition to the app folders - cotton will search for the "templates" directory (see above).
If not set, the `BASE_DIR` generated by `django-admin startproject` is used as a fallback, if it exists.
</div>
</div>
<p>Set to `False` to allow global context to be available through all components. (see <a href="{% url 'components' %}#context-isolation">context isolation</a> for more information.</p>
<c-hr />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
<div>
<code>COTTON_SNAKE_CASED_NAMES</code>
<div>bool (default: True)</div>
</div>
<div>
<div class="mb-4">By default cotton will look for snake case versions of your component names. To turn this behaviour off (useful if you want to permit hyphenated filenames) then set this key to <code>False</code>.</div>
<div class="mb-4">
<h6 class="mb-1">Example:</h6>
<code>{{ '<c-my-button />'|force_escape }}</code>
</div>
<div class="mb-4">
<h6>As <code>True</code> (default)</h6>
Filepath: `cotton/my_button.html`
</div>
<div>
<h6>As <code>False</code></h6>
Filepath: `cotton/my-button.html`
</div>
</div>
</div>
</c-layouts.with-sidebar>
</c-layouts.with-sidebar>

View file

@ -1 +1 @@
<span class="font-bold dark:text-white text-[#1a384a] font-semibold border-b-2 border-teal-600 border-opacity-70">{{ slot }}</span>
<span class="dark:text-white text-[#1a384a] font-semibold border-b-2 border-teal-600 border-opacity-70">{{ slot }}</span>

View file

@ -1 +1 @@
<div {{ attrs }} class="h-[2px] bg-yellow-900 bg-opacity-10 my-8 w-full dark:bg-gray-700"></div>
<div {{ attrs }} class="h-[1px] bg-yellow-900 bg-opacity-10 mb-6 mt-12 w-full dark:bg-gray-700"></div>

View file

@ -0,0 +1,3 @@
<svg {{ attrs }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>

After

Width:  |  Height:  |  Size: 219 B

View file

@ -0,0 +1,3 @@
<svg {{ attrs }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 15.75 7.5-7.5 7.5 7.5" />
</svg>

After

Width:  |  Height:  |  Size: 219 B

View file

@ -0,0 +1,3 @@
<svg {{ attrs }} fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 24 24">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M8 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" /><path d="M14 12a1 1 0 1 0 2 0a1 1 0 0 0 -2 0" /><path d="M15.5 17c0 1 1.5 3 2 3c1.5 0 2.833 -1.667 3.5 -3c.667 -1.667 .5 -5.833 -1.5 -11.5c-1.457 -1.015 -3 -1.34 -4.5 -1.5l-.972 1.923a11.913 11.913 0 0 0 -4.053 0l-.975 -1.923c-1.5 .16 -3.043 .485 -4.5 1.5c-2 5.667 -2.167 9.833 -1.5 11.5c.667 1.333 2 3 3.5 3c.5 0 2 -2 2 -3" /><path d="M7 16.5c3.5 1 6.5 1 10 0" />
</svg>

After

Width:  |  Height:  |  Size: 665 B

View file

@ -1,13 +1,3 @@
{% comment %}
<svg {{ attrs }} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 3v2.25m6.364.386-1.591 1.591M21 12h-2.25m-.386 6.364-1.591-1.591M12 18.75V21m-4.773-4.227-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0Z"/>
</svg>{% endcomment %}
<svg {{ attrs }} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0"/>
<path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7"/>
</svg>
<svg {{ attrs }} xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" /><path d="M3 12h1m8 -9v1m8 8h1m-9 8v1m-6.4 -15.4l.7 .7m12.1 -.7l-.7 .7m0 11.4l.7 .7m-12.1 -.7l-.7 .7" />
</svg>

Before

Width:  |  Height:  |  Size: 894 B

After

Width:  |  Height:  |  Size: 412 B

Before After
Before After

View file

@ -1 +1 @@
<li class="py-0.5 pl-1 ml-5 list-disc marker:text-teal-600 text-[15px] text-[#1a384a] dark:text-gray-400">{{ slot }}</li>
<li class="py-0.5 pl-1 ml-5 list-disc marker:text-teal-600 text-[#1a384a] dark:text-gray-400">{{ slot }}</li>

View file

@ -11,6 +11,7 @@
<link rel="icon" type="image/png" href="{% static 'favicon/favicon.png' %}">
<script src="{% static 'highlight/highlight.min.js' %}"></script>
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.x.x/dist/cdn.min.js"></script>
<script defer src="{% static 'alpine3.min.js' %}"></script>
<link href="{% static 'highlight/styles/atom-one-dark.min.css' %}" rel="stylesheet" />
<script>

View file

@ -4,15 +4,76 @@
<c-sidebar />
</div>
<div class="overflow-auto flex-1 pb-10">
<div class="max-w-[760px] mx-auto px-0 pt-3 md:pt-0 md:px-5 py-0 prose !text-[16.5px] prose-a:text-teal-600 prose-h2:text-3xl prose-h3:text-2xl prose-headings:text-[#1a384a] text-[#1a384a] dark:text-white dark:text-opacity-70 prose-headings:font-semibold dark:prose-headings:text-white">
<div class="
max-w-[760px]
mx-auto
px-0
pt-3
md:pt-0
md:px-5
py-0
!text-[16.5px]
text-[#1a384a]
prose
prose-a:text-teal-600
prose-h1:text-3xl
prose-h2:text-2xl
prose-h3:text-xl
prose-headings:text-[#1a384a]
prose-headings:font-semibold
prose-code:before:content-none
prose-code:after:content-none
prose-code:bg-yellow-900/10
prose-code:rounded
prose-code:px-1
prose-code:inline-block
prose-code:my-0
dark:text-white
dark:text-opacity-70
dark:prose-headings:text-white
dark:prose-code:bg-gray-800
dark:prose-code:text-gray-300/80
dark:prose-strong:text-[#fff]
">
{% if page_index %}
<div class="lg:hidden not-prose mb-6">
<c-ui.collapse>
<c-slot name="trigger">
<c-ui.button theme="subtle" class="w-full">
<span class="flex w-full justify-between items-center">
<span>On this page</span>
<template x-if="!expanded">
<c-icons.chevron-down class="size-5" />
</template>
<template x-if="expanded">
<c-icons.chevron-up class="size-5" />
</template>
</span>
</c-ui.button>
</c-slot>
<div class="mt-5">
{{ page_index }}
</div>
<c-hr />
</c-ui.collapse>
</div>
{% endif %}
{{ slot }}
</div>
</div>
<div class="relative h-full pl-10 border-opacity-10 hidden lg:block">
<div class="relative h-full pl-10 border-opacity-10 hidden lg:block text-[15px]">
{% if page_index %}
<div class="sticky top-[68px] pt-0 pb-10">
<c-sidebar-heading>On this page</c-sidebar-heading>
<ul class="ml-0 pl-0 mt-4">
<ul class="ml-0 pl-0 mt-4 ">
{{ page_index }}
</ul>
</div>

View file

@ -43,13 +43,13 @@
<div class="flex items-center space-x-1">
<c-icons.logo class="w-8 h-8 text-teal-600" />
<span class="font-bold text-2xl ml-1.5 text-[#1a384a] dark:text-gray-100">cotton</span>
<div class="text-gray-700 dark:text-white text-sm opacity-50 mt-0.5 px-1">for</div>
<c-icons.django class="h-6 text-teal-600 mt-1.5" />
<div class=" text-gray-700 dark:text-white text-sm opacity-50 mt-0.5 px-1">for</div>
<c-icons.django class=" h-6 text-teal-600 mt-1.5" />
</div>
</a>
</div>
<div class="flex items-center space-x-3">
<div class="flex items-center space-x-2">
<a href="#" @click.prevent="toggleDark" x-data="{
'dark': false,
init() {
@ -64,10 +64,13 @@
localStorage.theme = this.dark ? 'dark' : 'light'
}
}">
<c-icons.sun class="size-8 text-yellow-900 opacity-60 dark:text-gray-100" stroke-width="1.5" />
<c-icons.sun class="size-7 text-yellow-900/40 dark:text-gray-100/50" />
</a>
<a href="https://github.com/wrabit/django-cotton" target="_blank" class="text-yellow-900 opacity-60 dark:text-gray-100 flex items-center space-x-2">
<c-icons.github class="size-7" />
<a href="https://github.com/wrabit/django-cotton" target="_blank" class="text-yellow-900/40 dark:text-gray-100/50 flex items-center space-x-2">
<c-icons.github class="size-6" />
</a>
<a href="https://discord.gg/4x8ntQwHMe" target="_blank" class="text-yellow-900/40 dark:text-gray-100/50 flex items-center space-x-2">
<c-icons.discord class="size-7" />
</a>
</div>
</div>
@ -83,4 +86,4 @@
@click.away="isOpen = false" class="absolute shadow-md bg-white dark:bg-gray-800 top-[67px] inset-x-0 px-5 transition transform origin-top-right md:hidden">
<c-sidebar />
</div>
</div>
</div>

View file

@ -1,3 +1,3 @@
<div class="mt-6 border border-teal-600 rounded-lg px-8 py-5 dark:border dark:bg-transparent">
<div class="mt-6 border-2 border-teal-500 bg-teal-500/10 rounded-lg px-8 py-5">
{{ slot }}
</div>

View file

@ -1,5 +1,4 @@
<div class="sticky top-[68px] pb-6 pt-2">
<c-sidebar-block>
<c-slot name="title">Getting Started</c-slot>
<ul>
@ -9,6 +8,9 @@
<c-sidebar-link url="{% url 'components' %}">
Components
</c-sidebar-link>
<c-sidebar-link url="{% url 'usage-patterns' %}">
Usage Patterns
</c-sidebar-link>
</ul>
</c-sidebar-block>
<c-sidebar-block>

View file

@ -1,4 +1,4 @@
<div class="py-4 {{ class }} border-b border-yellow-900 border-opacity-15 dark:border-gray-700 dark:border-opacity-100 last:border-0 first:pt-0 leading-7">
<div class="py-4 {{ class }} first:pt-0 leading-7">
{% if title %}
<c-sidebar-heading>{{ title }}</c-sidebar-heading>
{% endif %}

View file

@ -1 +1 @@
<div class="dark:text-white text-[#1a384a] dark:opacity-35 text-yellow-800 brightness-65 text-opacity-50 font-semibold uppercase text-[14px] mb-2 tracking-wider">{{ slot }}</div>
<div class="dark:text-white dark:opacity-35 text-yellow-900/40 font-semibold uppercase text-[14px] mb-2 tracking-wider">{{ slot }}</div>

View file

@ -0,0 +1,39 @@
<c-vars
:themes="{
'primary': 'bg-sky-500',
'subtle': 'bg-neutral-50 text-gray-800 dark:bg-gray-300/10 dark:text-neutral-300',
'danger': 'bg-red-500',
'warning': 'bg-yellow-500',
'info': 'bg-blue-500',
}"
theme="primary"
:outlined-themes="{
'primary': 'border border-sky-500 text-sky-500',
'subtle': 'border border-neutral-50 text-gray-800',
'danger': 'border border-red-500 text-red-500',
'warning': 'border border-yellow-500 text-yellow-500',
'info': 'border border-blue-500 text-blue-500',
}"
:outlined="False"
class
/>
<button {{ attrs }} type="button" class="
{% if outlined %}
{{ outlined_themes|get_item:theme }}
{% else %}
{{ themes|get_item:theme }}
{% endif %}
cursor-pointer whitespace-nowrap rounded-md
px-4 py-2 text-sm font-medium tracking-wide transition hover:opacity-75 text-center
focus-visible:outline
focus-visible:outline-2
focus-visible:outline-offset-2
focus-visible:outline-sky-500
active:opacity-100
active:outline-offset-0
disabled:opacity-75
disabled:cursor-not-allowed
{{ class }}">
{{ slot }}
</button>

View file

@ -0,0 +1,13 @@
<c-vars trigger_text trigger />
<div x-data="{ expanded: false }">
{% if trigger %}
<span @click="expanded = ! expanded" class="cursor-pointer">{{ trigger }}</span>
{% else %}
<button @click="expanded = ! expanded">{% firstof trigger_text "Toggle" %}</button>
{% endif %}
<div x-show="expanded" x-collapse x-cloak>
{{ slot }}
</div>
</div>

View file

@ -0,0 +1,41 @@
<c-vars trigger_text />
<div x-data="{ isOpen: false, openedWithKeyboard: false }" class="relative" @keydown.esc.window="isOpen = false, openedWithKeyboard = false">
<!-- Toggle Button -->
<button
type="button"
@click="isOpen = ! isOpen"
class="inline-flex cursor-pointer items-center gap-2 whitespace-nowrap rounded-md border border-neutral-300 bg-neutral-50 px-4 py-2 text-sm font-medium tracking-wide transition
hover:opacity-75
focus-visible:outline
focus-visible:outline-2
focus-visible:outline-offset-2
focus-visible:outline-neutral-800
dark:border-neutral-700
dark:bg-gray-800
dark:focus-visible:outline-neutral-300"
aria-haspopup="true"
@keydown.space.prevent="openedWithKeyboard = true"
@keydown.enter.prevent="openedWithKeyboard = true"
@keydown.down.prevent="openedWithKeyboard = true"
:class="isOpen || openedWithKeyboard ? 'text-neutral-900 dark:text-white' : 'text-neutral-600 dark:text-neutral-300'"
aria-expanded="isOpen || openedWithKeyboard">
{% firstof trigger_text "Select" %}
<svg aria-hidden="true" fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4 totate-0">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/>
</svg>
</button>
<!-- Dropdown Menu -->
<div x-cloak x-show="isOpen || openedWithKeyboard"
x-transition
x-trap="openedWithKeyboard"
@click.outside="isOpen = false, openedWithKeyboard = false"
@keydown.down.prevent="$focus.wrap().next()"
@keydown.up.prevent="$focus.wrap().previous()"
class="absolute top-11 left-0 flex w-full min-w-[12rem] flex-col overflow-hidden rounded-md border border-neutral-300 bg-neutral-50 py-1.5
dark:border-neutral-700
dark:bg-gray-800"
role="menu">
{{ slot }}
</div>
</div>

View file

@ -0,0 +1,8 @@
<a href="#" class="bg-neutral-50 px-4 py-2 text-sm text-neutral-600
hover:bg-neutral-900/5
hover:text-neutral-900 focus-visible:bg-neutral-900/10 focus-visible:text-neutral-900 focus-visible:outline-none
dark:bg-black/10
dark:text-neutral-300
dark:hover:bg-neutral-50/5 dark:hover:text-white
dark:focus-visible:bg-neutral-50/10
dark:focus-visible:text-white" role="menuitem">{{ slot }}</a>

View file

@ -41,7 +41,7 @@
<h2 id="type">Change the input type</h2>
<p>You will probably need more than just a text input in your project. So let's declare an attribute `text` in <c-highlight>{{ '<c-vars />'|force_escape }}</c-highlight>. Adding it as a var will allow us to set "text" as the default. Additionally, it will be excluded from <c-highlight>{% verbatim %}{{ attrs }}{% endverbatim %}</c-highlight>:</p>
<p>You will probably need more than just a text input in your project. So let's declare an attribute `text` in <code>{{ '<c-vars />'|force_escape }}</code>. Adding it as a var will allow us to set "text" as the default. Additionally, it will be excluded from <code>{% verbatim %}{{ attrs }}{% endverbatim %}</code>:</p>
<c-snippet label="cotton/input.html">{% cotton_verbatim %}{% verbatim %}
<c-vars type="text" />
@ -140,7 +140,7 @@
<c-navigation>
<c-slot name="prev">
<a href="{% url 'components' %}">Components</a>
<a href="{% url 'usage-patterns' %}">Usage Patterns</a>
</c-slot>
<c-slot name="next">
<a href="{% url 'alpine-js' %}">Tabs with Alpine.js</a>

View file

@ -20,7 +20,7 @@
<div class="w-full shrink-0"></div>
<span class="pt-0 md:pt-5 clear-both flex-wrap inline-flex justify-center items-center space-x-2">
<span class="">Hello</span>
<span class="shrink-0 px-3 py-1 bg-teal-600 font-mono text-xl sm:text-2xl md:text-4xl rounded-lg text-white inline-block font-normal leading-normal">{{ '&lt;c-component />' }}</span>
<span class="shrink-0 px-3 py-1 bg-teal-500/10 border-[3px] border-teal-600 font-mono text-xl sm:text-2xl md:text-4xl rounded-3xl text-teal-600 dark:text-white inline-block font-normal leading-normal">{{ '<c-comp />'|force_escape }}</span>
</span>
</h1>
@ -32,7 +32,7 @@
<div class="grid md:grid-cols-2 gap-4">
<div class="col-span-1 flex flex-col overflow-x-auto">
<h2 class="!font-normal !text-xl mb-3 mt-0 text-center"><span class="font-semibold">Before:</span> Strongly Coupled, Verbose</h2>
<h2 class="!font-normal !text-lg mb-3 mt-0 text-center"><span class="font-semibold">Before:</span> Strongly Coupled, Verbose</h2>
<div class="flex h-full rounded-xl overflow-hidden">
<c-demo.snippet-tabs labels="view.html|product_layout.html" tabs_id="compare">
<div class="flex flex-col h-full" x-show="active === 0">
@ -44,7 +44,7 @@
icon.png
{% endblock %}
{% block header %}
{% block title %}
Item Title
{% endblock %}
@ -88,7 +88,7 @@ Item Title
</div>
</div>
<div class="col-span-1 rounded-lg overflow-hidden flex flex-col">
<h2 class="!font-normal !text-xl mb-3 mt-0 text-center"><span class="font-semibold">After:</span> Decoupled, Clean &amp; Re-usable</h2>
<h2 class="!font-normal !text-lg mb-3 mt-0 text-center"><span class="font-semibold">After:</span> Decoupled, Clean &amp; Re-usable</h2>
<div class="flex h-full rounded-xl overflow-hidden">
<c-demo.snippet-tabs labels="view.html|product.html" tabs_id="compare">
<div class="flex flex-col h-full" x-show="active === 0">
@ -179,4 +179,4 @@ Item Title
</div>
</c-layouts.with-sidebar>
</c-layouts.with-sidebar>

View file

@ -2,23 +2,19 @@
<c-slot name="page_index">
<c-index-link><a href="#install" class="no-underline">Install Cotton</a></c-index-link>
<c-index-link><a href="#create-a-component" class="no-underline">Create a component</a></c-index-link>
<c-index-link><a href="#templates-location" class="no-underline">Templates location</a></c-index-link>
<c-index-link><a href="#include-a-component" class="no-underline">Include a component</a></c-index-link>
<c-index-link><a href="#usage" class="no-underline">Usage</a></c-index-link>
<c-index-sublink><a href="#basics" class="no-underline text-opacity-70">Basics</a></c-index-sublink>
<c-index-sublink><a href="#naming" class="no-underline">Naming</a></c-index-sublink>
<c-index-sublink><a href="#subfolders" class="no-underline">Subfolders</a></c-index-sublink>
<c-index-sublink><a href="#tag-style" class="no-underline">Tag Style</a></c-index-sublink>
</c-slot>
<h1 id="install">Quickstart</h1>
<h2>Install cotton</h2>
<h2 class="mt-0">Install cotton</h2>
<p>Run the following command:</p>
<c-snippet language="python">pip install django-cotton</c-snippet>
<p>Then update your settings.py:</p>
<h3>Automatic configuration:</h3>
<h3>For automatic configuration:</h3>
<c-snippet language="python" label="settings.py">{% cotton_verbatim %}{% verbatim %}
INSTALLED_APPS = [
@ -26,9 +22,9 @@ INSTALLED_APPS = [
]
{% endverbatim %}{% endcotton_verbatim %}</c-snippet>
<p>This will automatically handle the settings.py adding the required loader and templatetags.</p>
<p>This will attempt to automatically handle the settings.py by adding the required loader and templatetags.</p>
<h3>Customised configuration</h3>
<h3>For custom configuration</h3>
<p>If your project requires any non-default loaders or you do not wish Cotton to manage your settings, you should instead provide `django_cotton.apps.SimpleAppConfig` in your INSTALLED_APPS:</p>
@ -72,6 +68,21 @@ TEMPLATES = [
</div>
{% endverbatim %}{% endcotton_verbatim %}</c-snippet>
<c-hr id="templates-location" />
<h2>Templates location</h2>
<p>Cotton supports 2 common approaches regarding the location of the <code>templates</code> directory:</p>
<c-ul>
<li><strong>App level</strong> - You can place your cotton folder in any of your installed app folders, like: <div><code>[project]/[app]/templates/cotton/row.html</code></div></li>
<li>
<strong>Project root</strong> - You can place your cotton folder in a project level templates directory, like: <div><code>[project]/templates/cotton/row.html</code></div>
(<code>[project]</code> location is provided by `BASE_DIR` if present or you may set it with `COTTON_BASE_DIR`)
</li>
</c-ul>
<p>Any style will allow you to include your component the same way: <code>{{ '<c-row />'|force_escape }}</code></p>
<c-hr id="include-a-component" />
@ -105,34 +116,6 @@ def dashboard_view(request):
</c-slot>
</c-snippet>
<c-hr />
<h2 id="usage">Usage</h2>
<h3 id="basics">Basics</h3>
<c-ul>
<li>Cotton components should be placed in the <c-highlight>templates/cotton</c-highlight> folder (unless you have set COTTON_DIR).</li>
</c-ul>
<h3 id="naming">Naming</h3>
<p>Cotton uses the following naming conventions:</p>
<c-ul>
<li>Component file names are in snake_case: <c-highlight>my_component.html</c-highlight></li>
<li>but are called using kebab-case: <c-highlight>{{ "<c-my-component />"|force_escape }}</c-highlight></li>
</c-ul>
<h3 id="subfolders">Subfolders</h3>
<c-ul>
<li>Components in subfolders can be defined using dot notation</li>
<li>A component in <c-highlight>sidebar/menu/link.html</c-highlight> would be included as <c-highlight>{{ "<c-sidebar.menu.link />"|force_escape }}</c-highlight></li>
</c-ul>
<h3 id="tag-style">Tag Style</h3>
<c-ul>
<li>Components can either be self-closing <c-highlight>{{ "<c-my-component />"|force_escape }}</c-highlight> or have a closing tag <c-highlight>{{ "<c-my-component></c-my-component>"|force_escape }}</c-highlight></li>
</c-ul>
<c-navigation>
<c-slot name="prev">
<a href="{% url 'home' %}">Home</a>

View file

@ -0,0 +1,79 @@
<c-layouts.with-sidebar>
<h1 id="components">Usage Patterns</h1>
<c-slot name="page_index">
<c-index-link><a href="#components" class="no-underline">Components</a></c-index-link>
<c-index-sublink><a href="#basics" class="no-underline text-opacity-70">Basics</a></c-index-sublink>
<c-index-sublink><a href="#naming" class="no-underline">Naming</a></c-index-sublink>
<c-index-sublink><a href="#subfolders" class="no-underline">Subfolders</a></c-index-sublink>
<c-index-sublink><a href="#index" class="no-underline">index.html</a></c-index-sublink>
<c-index-sublink><a href="#tag-style" class="no-underline">Tag Style</a></c-index-sublink>
</c-slot>
<h3 id="basics">Basics</h3>
<c-ul>
<li>Cotton components should be placed in the <c-highlight>templates/cotton</c-highlight> folder ('cotton' path is <a href="{% url 'configuration' %}">configurable</a> using <code>COTTON_DIR</code>).</li>
<li>The <code>templates</code> folder can be located in either an app-level or top-level project root folder.</li>
</c-ul>
<c-hr />
<h3 id="naming">Naming</h3>
<p>Cotton uses the following naming conventions:</p>
<c-ul>
<li>By default, component file names are in snake_case (<c-highlight>my_component.html</c-highlight>).
<li>kebab-case filenames (<c-highlight>my-component.html</c-highlight>) can be enabled with <code>COTTON_SNAKE_CASED_NAMES = False</code> see <a href="{% url 'configuration' %}">configuration</a>.</li>
<li>Components are included in templates using kebab-case name of the component: <code>{{ "<c-my-component />"|force_escape }}</code></li>
</c-ul>
<c-hr />
<h3 id="subfolders">Subfolders</h3>
<c-ul>
<li>Components in subfolders can be called using dot notation to represent folder levels.</li>
<li>A component in <c-highlight>sidebar/menu/link.html</c-highlight> would be included with <code>{{ "<c-sidebar.menu.link />"|force_escape }}</code></li>
</c-ul>
<c-hr />
<h3 id="index">The index.html component</h3>
<p>When your component has sub-components, you can define the default component of a folder by adding an <code>index.html</code>. This helps to keep your project structure tidy and reduce additional code in the template.</p>
<c-snippet label="Project structure">
{% cotton_verbatim %}{% verbatim %}
templates/
├── cotton/
│ ├── card/
│ │ ├── index.html
│ │ ├── header.html
{% endverbatim %}{% endcotton_verbatim %}
</c-snippet>
<c-snippet label="Usage">
{% cotton_verbatim %}{% verbatim %}
<c-card>
<c-card.header>...</c-card.header>
</c-card>
{% endverbatim %}{% endcotton_verbatim %}
</c-snippet>
<c-hr />
<h3 id="tag-style">Tag Style</h3>
<c-ul>
<li>Components can either be self-closing <code>{{ "<c-my-component />"|force_escape }}</code> or have a closing tag <code>{{ "<c-my-component></c-my-component>"|force_escape }}</code></li>
</c-ul>
<c-navigation>
<c-slot name="prev">
<a href="{% url 'components' %}">Components</a>
</c-slot>
<c-slot name="next">
<a href="{% url 'form-fields' %}">Form Inputs</a>
</c-slot>
</c-navigation>
</c-layouts.with-sidebar>

View file

@ -10,8 +10,9 @@ urlpatterns = [
name="home",
),
path("docs/quickstart", views.build_view("quickstart"), name="quickstart"),
path("docs/installation", views.build_view("installation"), name="installation"),
path("docs/usage", views.build_view("usage"), name="usage"),
path(
"docs/usage-patterns", views.build_view("usage_patterns"), name="usage-patterns"
),
# Features
path("docs/components", views.build_view("components"), name="components"),
path("docs/slots", views.build_view("slots"), name="slots"),

View file

@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry]
name = "django-cotton"
version = "1.2.1"
version = "1.6.0"
description = "Bringing component based design to Django templates."
authors = [ "Will Abbott <willabb83@gmail.com>",]
license = "MIT"