first release

This commit is contained in:
Will Abbott 2024-06-08 14:33:11 +01:00
parent c38c990b5b
commit 4c4d00d4df
2269 changed files with 323498 additions and 2 deletions

43
.github/workflows/deploy_docs.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: "Deploy Documentation"
run-name: "Deploy Documentation (@${{ github.actor }})"
on:
workflow_call:
secrets:
RAILWAY_PROJECT_TOKEN:
required: true
RAILWAY_SERVICE:
required: true
push:
branches:
- main
paths:
- 'docs/**'
jobs:
deploy_docs:
env:
RAILWAY_TOKEN: ${{ secrets.RAILWAY_PROJECT_TOKEN }}
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: '16'
- name: Install Documentation Dependencies
run: |
cd docs/docs_project
npm install
- name: Install Railway CLI
run: npm install -g @railway/cli
- name: Deploy Documentation
run: |
cp -r django_cotton docs/docs_project
railway up --service=${{ secrets.RAILWAY_SERVICE }}

48
.github/workflows/publish_to_pypi.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: "Publish to PyPI"
run-name: "Publish to PyPI (@${{ github.actor }})"
on:
push:
branches:
- main
paths:
- '**'
- '!docs/**'
jobs:
publish_pypi:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.10'
- name: Install required libraries
run: python3 -m pip install toml
- name: Update Version in pyproject.toml
run: |
git fetch origin main
git reset --hard origin/main
python -c "import toml; f = open('./pyproject.toml', 'r'); c = toml.load(f); f.close(); v = list(map(int, c['tool']['poetry']['version'].split('.'))); v[-1] += 1; c['tool']['poetry']['version'] = '.'.join(map(str, v)); f = open('./pyproject.toml', 'w'); toml.dump(c, f); f.close();"
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
git commit -m "Automatic version bump" --allow-empty
git push origin main
- name: Build and publish to PyPI
uses: JRubics/poetry-publish@v1.17
with:
pypi_token: ${{ secrets.PYPI_TOKEN }}
trigger_deploy_docs:
needs: publish_pypi
uses: ./.github/workflows/deploy_docs.yml
secrets:
RAILWAY_PROJECT_TOKEN: ${{ secrets.RAILWAY_PROJECT_TOKEN }}
RAILWAY_SERVICE: ${{ secrets.RAILWAY_SERVICE }}

30
.github/workflows/test.yml vendored Normal file
View file

@ -0,0 +1,30 @@
name: Test
on:
workflow_dispatch:
pull_request:
branches:
- "main"
jobs:
run_tests:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Build
run: |
docker build -f dev/docker/Dockerfile -t cotton-test-app dev/example_project
- name: Start Container
run: |
docker-compose -f dev/docker/docker-compose.yaml up -d
- name: Run Tests
run: docker exec -t cotton-dev-app python manage.py test
- name: Stop and Remove Services
run: docker-compose -f dev/docker/docker-compose.yaml down

115
.gitignore vendored Normal file
View file

@ -0,0 +1,115 @@
# Django #
*.log
*.pot
*.pyc
__pycache__
db.sqlite3
media
**/static/app.css
# Backup files #
*.bak
# File-based project format
*.iws
# IntelliJ
out/
.idea/
# JIRA plugin
atlassian-ide-plugin.xml
# Python #
*.py[cod]
*$py.class
# Distribution / packaging
.Python build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.coverage
.coverage.*
.cache
.pytest_cache/
nosetests.xml
coverage.xml
*.cover
.hypothesis/
# Jupyter Notebook
.ipynb_checkpoints
# pyenv
.python-version
# celery
celerybeat-schedule.*
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mkdocs documentation
/site
# mypy
.mypy_cache/
# Sublime Text #
*.tmlanguage.cache
*.tmPreferences.cache
*.stTheme.cache
*.sublime-workspace
*.sublime-project
# sftp configuration file
sftp-config.json
# Package control specific files Package
Control.last-run
Control.ca-list
Control.ca-bundle
Control.system-ca-bundle
GitHub.sublime-settings
# Visual Studio Code #
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history
.DS_Store

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 dyvenia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

141
README.md
View file

@ -1,2 +1,139 @@
# cotton # Cotton
Bringing component based design to Django templates
Bringing component-based design to Django templates.
<a href="https://www.django-cotton.com" target="_blank">Document site</a>
## Overview
Cotton enhances Django templates by allowing component-based design, making UI composition more efficient and reusable. It integrates seamlessly with Tailwind CSS and retains full compatibility with native Django template features.
## Key Features
- **Rapid UI Composition:** Efficiently compose and reuse UI components.
- **Tailwind CSS Harmony:** Integrates with Tailwind's utility-first approach.
- **Interoperable with Django:** Enhances Django templates without replacing them.
- **Semantic Syntax:** HTML-like syntax for better code editor support.
- **Minimal Overhead:** Compiles to native Django components with automatic caching.
## Getting Started
### Installation
To install Cotton, run the following command:
```bash
pip install django-cotton
```
Then update your `settings.py`:
```python
INSTALLED_APPS = [
...
'django_cotton',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['your_project/templates'],
'APP_DIRS': False,
'OPTIONS': {
'loaders': [
'django_cotton.template.loaders.CottonLoader',
# continue with default loaders:
# "django.template.loaders.filesystem.Loader",
# "django.template.loaders.app_directories.Loader",
],
'builtins': [
'django_cotton.templatetags.cotton',
],
},
},
]
```
### Quickstart
Create a new directory in your templates directory called `cotton`. Inside this directory, create a new file called `card.cotton.html` with the following content:
```html
<div class="bg-white shadow rounded border p-4">
<h2>{{ title }}</h2>
<p>{{ slot }}</p>
<button href="{% url url %}">Read more</button>
</div>
```
Create a view with a template. Views that contain Cotton components must also use the `.cotton.html` extension:
```python
# views.py
def dashboard_view(request):
return render(request, "dashboard.cotton.html")
```
```html
<!-- templates/dashboard.cotton.html -->
<c-card title="Trees" url="trees">
We have the best trees
</c-card>
<c-card title="Spades" url="spades">
The best spades in the land
</c-card>
```
### Usage Basics
- **Template Extensions:** View templates including Cotton components should use the `.cotton.html` extension.
- **Component Placement:** Components should be placed in the `templates/cotton` folder.
- **Naming Conventions:**
- Component filenames use snake_case: `my_component.cotton.html`
- Components are called using kebab-case: `<c-my-component />`
### Example
A minimal example using Cotton components:
```html
<!-- my_component.cotton.html -->
{{ slot }}
<!-- my_view.cotton.html -->
<c-my-component>
<p>Some content</p>
</c-my-component>
```
### Attributes and Slots
Components can accept attributes and named slots for flexible content and behavior customization:
```html
<!-- weather.cotton.html -->
<p>It's {{ temperature }}<sup>{{ unit }}</sup> and the condition is {{ condition }}.</p>
<!-- view.cotton.html -->
<c-weather temperature="23" unit="c" condition="windy"></c-weather>
```
#### Passing Variables
To pass a variable from the parent's context, prepend the attribute with a `:`.
```html
<!-- view.cotton.html -->
<c-weather :unit="unit"></c-weather>
```
#### Named Slots
```html
<!-- weather_card.cotton.html -->
<div class="flex ...">
<h2>{{ day }}:</h2> {{ icon }} {{ label }}
</div>
<!-- view.cotton.html -->
<c-weather-card day="Tuesday">
<c-slot name="icon">
<svg>...</svg>
</c-slot>
<c-slot name="label">
<h2 class="text-yellow-500">Sunny</h2>
</c-slot>
</c-weather-card>
```

29
dev/docker/Dockerfile Normal file
View file

@ -0,0 +1,29 @@
ARG PLATFORM=linux/amd64
# Use an official Python runtime as a base image
FROM python:3.9-slim
# Keep logs unbuffered
ENV PYTHONUNBUFFERED 1
# Set the working directory in the container
WORKDIR /app
# Install Poetry
RUN pip install --upgrade pip \
&& pip install poetry
RUN apt-get update && \
apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Copy only dependencies definition to the docker image
COPY . /app/
# Install project dependencies
RUN poetry config virtualenvs.create false \
&& poetry install
CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ]

16
dev/docker/bin/build.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
# if arg exists 'mac', then build for mac
if [ "$1" = "mac" ]; then
PLATFORM="linux/arm64"
else
PLATFORM="linux/amd64"
fi
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR
docker build --no-cache --build-arg PLATFORM=$PLATFORM -f ../Dockerfile ../../example_project -t cotton-test-app

4
dev/docker/bin/manage.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Start a new login shell to preserve history and execute the Docker command
bash -l -c "docker exec -it cotton-dev-app python manage.py $*"

9
dev/docker/bin/run-dev.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR
docker compose -f ../docker-compose.yaml up "$@"

9
dev/docker/bin/shell.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR/..
docker exec -it cotton-dev-app python manage.py shell

9
dev/docker/bin/stop.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR/..
docker compose -f etc/docker/docker-compose.yaml -f etc/docker/docker-compose.dev.yaml down $*

3
dev/docker/bin/terminal.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker exec -it cotton-dev-app bash

3
dev/docker/bin/test.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker exec -t cotton-dev-app python manage.py test $*

View file

@ -0,0 +1,16 @@
version: '3'
services:
web:
container_name: cotton-dev-app
restart: always
image: cotton-test-app
working_dir: /app
command: python manage.py runserver 0.0.0.0:8000
environment:
- DEBUG=True
volumes:
- ../example_project:/app
- ../../django_cotton:/app/django_cotton
ports:
- 8001:8000

View file

@ -0,0 +1,16 @@
"""
ASGI config for django_cotton project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
application = get_asgi_application()

View file

@ -0,0 +1,136 @@
"""
Django settings for django_cotton project.
Generated by 'django-admin startproject' using Django 4.2.8.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from pathlib import Path
# SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-%)7a&zw=le4uey^36*z*9^4#*iii65t)nyt$36mxq70@=(z6^n"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["0.0.0.0"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_cotton",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "django_cotton.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["example_project/templates", "django_cotton/templates"],
"APP_DIRS": False,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
"loaders": [
"django_cotton.cotton_loader.Loader",
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
"builtins": [
"django.templatetags.static",
"django_cotton.templatetags.cotton",
],
},
},
]
WSGI_APPLICATION = "django_cotton.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/4.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
COTTON_TEMPLATE_CACHING_ENABLED = False

View file

@ -0,0 +1,3 @@
<c-parent>
<c-child>d</c-child>
</c-parent>

View file

@ -0,0 +1,9 @@
<c-benchmarks.partials.main>
I'm default
<c-slot name="top">
I'm top
</c-slot>
<c-slot name="bottom">
I'm bottom
</c-slot>
</c-benchmarks.partials.main>

View file

@ -0,0 +1,9 @@
{% cotton_component cotton/benchmarks/partials/main.cotton.html %}
I'm default
{% cotton_slot top %}
I'm top
{% end_cotton_slot %}
{% cotton_slot bottom %}
I'm bottom
{% end_cotton_slot %}
{% end_cotton_component %}

View file

@ -0,0 +1,13 @@
{% extends "cotton/benchmarks/partials/native_main.html" %}
{% block top %}
I'm top
{% endblock %}
{% block content %}
I'm default
{% endblock %}
{% block bottom %}
I'm bottom
{% endblock %}

View file

@ -0,0 +1,5 @@
{{ top }}
{{ slot }}
{{ bottom }}

View file

@ -0,0 +1,5 @@
{% block top %}{% endblock %}
{% block content %}{% endblock %}
{% block bottom %}{% endblock %}

View file

@ -0,0 +1 @@
<div class="i-am-child"></div>

View file

@ -0,0 +1,3 @@
<div>
{{ name }}
</div>

View file

@ -0,0 +1,3 @@
<div class="i-am-parent">
{{slot}}
</div>

View file

@ -0,0 +1,12 @@
<c-props prop1="sds" prop_with_default="1" />
<div>
{{ testy }}
<p>prop1: '{{ prop1 }}'</p>
<p>attr1: '{{ attr1 }}'</p>
<p>empty_prop: '{{ empty_prop }}'</p>
<p>prop_with_default: '{{ prop_with_default }}'</p>
<p>slot: '{{ slot }}'</p>
<p>named_slot: '{{ named_slot }}'</p>
<p>attrs: '{{ attrs }}'</p>
</div>

View file

@ -0,0 +1,3 @@
<c-parent>
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
</c-parent>

View file

@ -0,0 +1,7 @@
{% for item in items %}
<c-named-slot-component>
<c-slot name="name">
item name: {{ item.name }}
</c-slot>
</c-named-slot-component>
{% endfor %}

View file

@ -0,0 +1 @@
<c-parent></c-parent>

View file

@ -0,0 +1,3 @@
<c-props-test-component prop1="im a prop" attr1="im an attr">
default slot
</c-props-test-component>

View file

@ -0,0 +1,4 @@
{% load static %}
<c-parent/>

22
dev/example_project/manage.py Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,24 @@
[project]
name = "cotton-dev-app"
requires-python = ">=3.8, <4"
dependencies = [
"django~=4.2.6",
"beautifulsoup4~=4.12.2",
"selenium~=4.13.0",
"chromedriver-py~=117.0.5938.92",
"webdriver-manager~=4.0.1"
]
[tool.poetry]
name = "cotton-dev-app"
version = "0.1"
description = "Development and test app for the django package."
authors = ["Will Abbott <wabbott@dyvenia.com>"]
[tool.poetry.dependencies]
python = "^3.8"
Django = "^4.2"
beautifulsoup4 = "~4.12.2"
selenium = "~4.13.0"
chromedriver-py = "~117.0.5938.92"
webdriver-manager = "~4.0.1"

View file

@ -0,0 +1,71 @@
from django.conf import settings
import time
from django.template.loader import render_to_string
import django
# Configure Django settings
settings.configure(
INSTALLED_APPS=[
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"django_cotton",
],
TEMPLATES=[
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": ["example_project/templates"],
"APP_DIRS": False,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
"loaders": [
"django_cotton.cotton_loader.Loader",
"django.template.loaders.filesystem.Loader",
"django.template.loaders.app_directories.Loader",
],
"builtins": [
"django.templatetags.static",
"django_cotton.templatetags.cotton",
],
},
},
],
)
django.setup()
def benchmark_template_rendering(template_name, iterations=1000):
start_time = time.time()
for _ in range(iterations):
render_to_string(template_name)
end_time = time.time()
return end_time - start_time, render_to_string(template_name)
# Benchmarking each template
time_native_extends, output_native_extends = benchmark_template_rendering(
"cotton/benchmarks/native_extends.html"
)
# time_native_include, output_native_include = benchmark_template_rendering('cotton/benchmarks/native_include.html')
time_compiled_cotton, output_compiled_cotton = benchmark_template_rendering(
"cotton/benchmarks/cotton_compiled.html"
)
time_cotton, output_cotton = benchmark_template_rendering(
"cotton/benchmarks/cotton.cotton.html"
)
# Output results
print(f"Native Django Template using extend: {time_native_extends} seconds")
# print(f"Native Django Template using include: {time_native_include} seconds")
print(f"Compiled Cotton Template: {time_compiled_cotton} seconds")
print(f"Cotton Template: {time_cotton} seconds")

View file

@ -0,0 +1 @@
_with_prop_prefix = "cotton_with_prop_"

291
django_cotton/cotton_loader.py Executable file
View file

@ -0,0 +1,291 @@
import os
import re
import hashlib
import warnings
from django.template.loaders.base import Loader as BaseLoader
from django.core.exceptions import SuspiciousFileOperation
from django.template import TemplateDoesNotExist
from django.utils._os import safe_join
from django.template import Template
from django.core.cache import cache
from django.template import Origin
from django.conf import settings
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
class Loader(BaseLoader):
is_usable = True
def __init__(self, engine, dirs=None):
super().__init__(engine)
self.cache_handler = CottonTemplateCacheHandler()
self.dirs = dirs
self.django_syntax_placeholders = []
def get_template_from_string(self, template_string):
"""Create and return a Template object from a string. Used primarily for testing."""
return Template(template_string, engine=self.engine)
def get_contents(self, origin):
# check if file exists, whilst getting the mtime for cache key
try:
mtime = os.path.getmtime(origin.name)
except FileNotFoundError:
raise TemplateDoesNotExist(origin)
# check and return cached template
cache_key = self.cache_handler.get_cache_key(origin.template_name, mtime)
cached_content = self.cache_handler.get_cached_template(cache_key)
if cached_content is not None:
return cached_content
# If not cached, process the template
template_string = self._get_template_string(origin.name)
# We need to provide a key to the current view or component (in this case, view) so that we can namespace
# slot data, preventing bleeding and ensure component's clear only data in the context applicable to itself
# in this case, we're top level, likely in a view so we use the view template name as the key
component_key = (
origin.template_name.lstrip("cotton/")
.rstrip(".cotton.html")
.replace("/", ".")
)
compiled_template = self._compile_template_from_string(
template_string, component_key
)
# Cache the processed template
self.cache_handler.cache_template(cache_key, compiled_template)
return compiled_template
def _replace_syntax_with_placeholders(self, content):
"""# replace {% ... %} and {{ ... }} with placeholders so they dont get touched
or encoded by bs4. Store them to later switch them back in after transformation.
"""
self.django_syntax_placeholders = []
# First handle cotton_verbatim blocks, this is designed to preserve and display cotton syntax,
# akin to the verbatim tag in Django.
def replace_cotton_verbatim(match):
inner_content = match.group(
1
) # Get the inner content without the cotton_verbatim tags
self.django_syntax_placeholders.append(inner_content)
return f"__django_syntax__{len(self.django_syntax_placeholders)}__"
# Replace cotton_verbatim blocks, capturing inner content
content = re.sub(
r"\{% cotton_verbatim %\}(.*?)\{% endcotton_verbatim %\}",
replace_cotton_verbatim,
content,
flags=re.DOTALL,
)
content = re.sub(
r"\{%.*?%\}",
lambda x: self.django_syntax_placeholders.append(x.group(0))
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
content,
)
content = re.sub(
r"\{\{.*?\}\}",
lambda x: self.django_syntax_placeholders.append(x.group(0))
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
content,
)
return content
def _replace_placeholders_with_syntax(self, content):
"""After modifying the content, replace the placeholders with the django template tags and variables."""
for i, placeholder in enumerate(self.django_syntax_placeholders, 1):
content = content.replace(f"__django_syntax__{i}__", placeholder)
return content
def _get_template_string(self, template_name):
try:
with open(template_name, "r") as f:
content = f.read()
except FileNotFoundError:
raise TemplateDoesNotExist(template_name)
return content
def _compile_template_from_string(self, content, component_key):
content = self._replace_syntax_with_placeholders(content)
content = self._compile_cotton_to_django(content, component_key)
content = self._replace_placeholders_with_syntax(content)
content = self._revert_bs4_attribute_empty_attribute_fixing(content)
return content
def _revert_bs4_attribute_empty_attribute_fixing(self, contents):
"""Django's template parser adds ="" to empty attribute-like parts in any html-like node, i.e. <div {{ something }}> gets
compiled to <div {{ something }}=""> Then if 'something' is holding attributes sets, the last attribute value is
not quoted. i.e. model=test not model="test"."""
cleaned_content = re.sub('}}=""', "}}", contents)
return cleaned_content
def get_dirs(self):
return self.dirs if self.dirs is not None else self.engine.dirs
def get_template_sources(self, template_name):
"""Return an Origin object pointing to an absolute path in each directory
in template_dirs. For security reasons, if a path doesn't lie inside
one of the template_dirs it is excluded from the result set."""
if template_name.endswith(".cotton.html"):
for template_dir in self.get_dirs():
try:
name = safe_join(template_dir, template_name)
except SuspiciousFileOperation:
# The joined path was located outside of this template_dir
# (it might be inside another one, so this isn't fatal).
continue
yield Origin(
name=name,
template_name=template_name,
loader=self,
)
def _compile_cotton_to_django(self, html_content, component_key):
"""Convert cotton <c-* syntax to {%."""
soup = BeautifulSoup(html_content, "html.parser")
soup = self._wrap_with_cotton_props_frame(soup)
self._transform_components(soup, component_key)
return str(soup)
def _transform_prop_tags(self, soup):
c_props = soup.find_all("c-props")
for tag in c_props:
# Build the cotton_props tag string
props_list = []
for prop, value in tag.attrs.items():
if value is None:
props_list.append(prop)
else:
props_list.append(f'{prop}="{value}"')
cotton_props_str = "{% cotton_props " + " ".join(props_list) + " %}"
# Replace the <c-props> tag with the cotton_props string.
tag.replace_with(cotton_props_str)
return soup
def _wrap_with_cotton_props_frame(self, soup):
"""Wrap content with {% cotton_props_frame %} to be able to govern props and attributes. In order to recognise
props defined in a component and also have them available in context, we wrap the entire contents in another
component: cotton_props_frame."""
props_with_defaults = []
c_props = soup.find("c-props")
# parse c-props tag to extract properties and defaults
if c_props:
props_with_defaults = []
for prop, value in c_props.attrs.items():
if value is None:
props_with_defaults.append(f"{prop}={prop}")
else:
# Assuming value is already a string that represents the default value
props_with_defaults.append(f'{prop}={prop}|default:"{value}"')
c_props.decompose()
# Construct the {% with %} opening tag
opening = "{% cotton_props_frame " + " ".join(props_with_defaults) + " %}"
closing = "{% endcotton_props_frame %}"
# Convert the remaining soup back to a string and wrap it within {% with %} block
wrapped_content = opening + str(soup).strip() + closing
# Since we can't replace the soup object itself, we create new soup instead
new_soup = BeautifulSoup(wrapped_content, "html.parser")
return new_soup
def _transform_named_slot(self, slot_tag, component_key):
"""Replace <c-slot> tags with the {% cotton_slot %} template tag"""
# for c_slot in soup.find_all("c-slot"):
slot_name = slot_tag.get("name", "").strip()
inner_html = "".join(str(content) for content in slot_tag.contents)
# Check and process any components in the slot content
slot_soup = BeautifulSoup(inner_html, "html.parser")
self._transform_components(slot_soup, component_key)
cotton_slot_tag = f"{{% cotton_slot {slot_name} {component_key} %}}{str(slot_soup)}{{% end_cotton_slot %}}"
slot_tag.replace_with(BeautifulSoup(cotton_slot_tag, "html.parser"))
def _transform_components(self, soup, component_key):
"""Replace <c-[component path]> tags with the {% cotton_component %} template tag"""
for tag in soup.find_all(re.compile("^c-"), recursive=True):
if tag.name == "c-slot":
self._transform_named_slot(tag, component_key)
continue
component_name = tag.name[2:]
# Convert dot notation to path structure and replace hyphens with underscores
path = component_name.replace(".", "/").replace("-", "_")
# Construct the opening tag
opening_tag = f"{{% cotton_component {'cotton/{}.cotton.html'.format(path)} {component_name} "
for attr, value in tag.attrs.items():
if attr == "class":
value = " ".join(value)
opening_tag += ' {}="{}"'.format(attr, value)
opening_tag += " %}"
# Construct the closing tag
closing_tag = "{% end_cotton_component %}"
if tag.contents:
tag_soup = BeautifulSoup(tag.decode_contents(), "html.parser")
self._transform_components(tag_soup, component_name)
# Create new content with opening tag, tag content, and closing tag
new_content = opening_tag + str(tag_soup) + closing_tag
else:
# Create new content with opening tag and closing tag
new_content = opening_tag + closing_tag
# Replace the original tag with the new content
new_soup = BeautifulSoup(new_content, "html.parser")
tag.replace_with(new_soup)
return soup
class CottonTemplateCacheHandler:
"""Handles caching of cotton templates so the html parsing is only done on first load of each view or component."""
def __init__(self):
self.enabled = getattr(settings, "TEMPLATE_CACHING_ENABLED", True)
def get_cache_key(self, template_name, mtime):
template_hash = hashlib.sha256(template_name.encode()).hexdigest()
return f"cotton_cache_{template_hash}_{mtime}"
def get_cached_template(self, cache_key):
if not self.enabled:
return None
return cache.get(cache_key)
def cache_template(self, cache_key, content, timeout=None):
if self.enabled:
cache.set(cache_key, content, timeout=timeout)

View file

@ -0,0 +1,3 @@
<c-merges-attributes class="extra-class" silica:model="test" another="test">
ss
</c-merges-attributes>

View file

@ -0,0 +1,3 @@
<c-receives-attributes attribute_1="hello" and-another="woo1" thirdForLuck="yes">
ss
</c-receives-attributes>

View file

@ -0,0 +1,3 @@
<c-parent>
<c-child>d</c-child>
</c-parent>

View file

@ -0,0 +1 @@
<div class="i-am-child"></div>

View file

@ -0,0 +1,11 @@
<div>
Header:
{{ header }}
</div>
<div>
Content:
{{ slot }}
</div>

View file

@ -0,0 +1,3 @@
<div {{ attrs_dict|merge:'class:form-group another-class-with:colon' }}>
</div>

View file

@ -0,0 +1,3 @@
<div>
{{ name }}
</div>

View file

@ -0,0 +1,3 @@
<div class="i-am-parent">
{{slot}}
</div>

View file

@ -0,0 +1,3 @@
<div {{ attrs }}>
</div>

View file

@ -0,0 +1,13 @@
<c-props prop1 default_prop="default prop" />
<p>slot: '{{ slot }}'</p>
<p>attr1: '{{ attr1 }}'</p>
<p>attr2: '{{ attr2 }}'</p>
<p>prop1: '{{ prop1 }}'</p>
<p>default_prop: '{{ default_prop }}'</p>
<p>named_slot: '{{ named_slot }}'</p>
<p>attrs: '{{ attrs }}'</p>

View file

@ -0,0 +1 @@
<div class="{% if 1 < 2 %} some-class {% endif %}">Hello, World!</div>

View file

@ -0,0 +1,3 @@
<c-parent>
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
</c-parent>

View file

@ -0,0 +1,7 @@
{% for item in items %}
<c-named-slot-component>
<c-slot name="name">
item name: {{ item.name }}
</c-slot>
</c-named-slot-component>
{% endfor %}

View file

@ -0,0 +1 @@
<c-parent></c-parent>

View file

@ -0,0 +1,4 @@
{% load static %}
<c-parent/>

View file

@ -0,0 +1,5 @@
<c-test-component prop1="string with space" attr1="I have spaces">
<c-slot name="named_slot">
named_slot with spaces
</c-slot>
</c-test-component>

View file

@ -0,0 +1,2 @@
<c-test-component attr1="variable" :attr2="variable">
</c-test-component>

View file

View file

@ -0,0 +1,68 @@
from django import template
from django.template import Node
from django.template.loader import render_to_string
def cotton_component(parser, token):
bits = token.split_contents()
tag_name = bits[0]
template_path = bits[1]
component_key = bits[2]
kwargs = {}
for bit in bits[3:]:
key, value = bit.split("=")
if key.startswith(":"): # Detect variables
key = key[1:] # Remove ':' prefix
value = value.strip("'\"") # Remove quotes
kwargs[key] = template.Variable(value) # Treat as a variable
else:
kwargs[key] = value.strip("'\"") # Treat as a literal string
nodelist = parser.parse(("end_cotton_component",))
parser.delete_first_token()
return CottonComponentNode(nodelist, template_path, component_key, kwargs)
class CottonComponentNode(Node):
def __init__(self, nodelist, template_path, component_key, kwargs):
self.nodelist = nodelist
self.template_path = template_path
self.component_key = component_key
self.kwargs = kwargs
def render(self, context):
local_context = context.flatten()
attrs = {}
for key, value in self.kwargs.items():
if isinstance(value, template.Variable): # Resolve variables
try:
resolved_value = value.resolve(context)
attrs[key] = resolved_value
except template.VariableDoesNotExist:
pass # Handle variable not found, if necessary
else:
attrs[key] = value # Use literal string
# Add the remainder as the default slot
rendered = self.nodelist.render(context)
local_context.update({"slot": rendered})
slots = context.get("cotton_slots", {})
component_slots = slots.get(self.component_key, {})
local_context.update(component_slots)
local_context.update(attrs)
local_context.update({"attrs_dict": attrs})
rendered = render_to_string(self.template_path, local_context)
# Now reset the component's slots in context to prevent bleeding
if self.component_key in slots:
slots[self.component_key] = {}
context.update({"cotton_slots": slots})
return rendered

View file

@ -0,0 +1,43 @@
from django import template
register = template.Library()
def cotton_props(parser, token):
# Split the token to get variable assignments
parts = token.split_contents()
cotton_props = {}
for part in parts[1:]:
key, value = part.split("=")
cotton_props[key] = value
return CottonPropNode(cotton_props)
class CottonPropNode(template.Node):
def __init__(self, cotton_props):
self.cotton_props = cotton_props
def render(self, context):
resolved_props = {}
# if the same var is already set in context, it's being passed explicitly to override the cotton_var
# if not, then we resolve it from the context
for key, value in self.cotton_props.items():
# if key in context:
# resolved_props[key] = context[key]
# continue
try:
resolved_props[key] = template.Variable(value).resolve(context)
except (TypeError, template.VariableDoesNotExist):
resolved_props[key] = value
cotton_props = {"cotton_props": resolved_props}
# Update the global context directly
context.update(resolved_props)
context.update(cotton_props)
context.update({"cotton_props": resolved_props})
context["cotton_props"].update(resolved_props)
return ""

View file

@ -0,0 +1,64 @@
from django import template
from django.template.base import token_kwargs
from django.utils.safestring import mark_safe
register = template.Library()
def cotton_props_frame(parser, token):
"""The job of the props frame is to filter component kwargs (attributes) against declared props. It has to be
second component because we desire to declare props (<c-props />) inside the component template and therefore the
component can not manipulate its own context from it's own template, instead we declare the props frame
directly inside component"""
bits = token.split_contents()[1:] # Skip the tag name
# Parse token kwargs while maintaining token order
tag_kwargs = token_kwargs(bits, parser)
nodelist = parser.parse(("endcotton_props_frame",))
parser.delete_first_token()
return CottonPropsFrameNode(nodelist, tag_kwargs)
class CottonPropsFrameNode(template.Node):
def __init__(self, nodelist, kwargs):
self.nodelist = nodelist
self.kwargs = kwargs
def render(self, context):
# Assume 'attrs' are passed from the parent and are available in the context
parent_attrs = context.get("attrs_dict", {})
# Initialize props based on the frame's kwargs and parent attrs
props = {}
for key, value in self.kwargs.items():
# Attempt to resolve each kwarg value (which may include template variables)
resolved_value = value.resolve(context)
# Check if the prop exists in parent attrs; if so, use it, otherwise use the resolved default
if key in parent_attrs:
props[key] = parent_attrs[key]
else:
props[key] = resolved_value
# Overwrite 'attrs' in the local context by excluding keys that are identified as props
attrs_without_props = {k: v for k, v in parent_attrs.items() if k not in props}
context["attrs_dict"] = attrs_without_props
# Provide all of the attrs as a string to pass to the component
def ensure_quoted(value):
if isinstance(value, str) and value.startswith('"') and value.endswith('"'):
return value
else:
return f'"{value}"'
attrs = " ".join(
[
f"{key}={ensure_quoted(value)}"
for key, value in attrs_without_props.items()
]
)
context.update({"attrs": mark_safe(attrs)})
context.update(attrs_without_props)
context.update(props)
return self.nodelist.render(context)

View file

@ -0,0 +1,48 @@
from django import template
from django.utils.safestring import mark_safe
def cotton_slot(parser, token):
try:
tag_name, slot_name, component_key = token.split_contents()
except ValueError:
raise template.TemplateSyntaxError("incomplete c-slot %r" % token.contents)
nodelist = parser.parse(("end_cotton_slot",))
parser.delete_first_token()
return CottonSlotNode(slot_name, nodelist, component_key)
class CottonSlotNode(template.Node):
def __init__(self, slot_name, nodelist, component_key):
self.slot_name = slot_name
self.nodelist = nodelist
self.component_key = component_key
def render(self, context):
# Add the rendered content to the context.
if "cotton_slots" not in context:
context.update({"cotton_slots": {}})
# context["cotton_slots"][self.slot_name] = mark_safe(output)
output = self.nodelist.render(context)
# with context.push():
# Temporarily store the slot's content in the new layer
# if "cotton_slots" not in context:
# context["cotton_slots"] = {}
if self.component_key not in context["cotton_slots"]:
context["cotton_slots"][self.component_key] = {}
# if self.slot_name not in context["cotton_slots"][self.component_key]:
# context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
# context.push()
# todo add scoping by component
# context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
return ""

View file

@ -0,0 +1,26 @@
from django import template
from django.utils.html import format_html_join
from django_cotton.templatetags._component import cotton_component
from django_cotton.templatetags._slot import cotton_slot
from django_cotton.templatetags._props import cotton_props
from django_cotton.templatetags._props_frame import cotton_props_frame
register = template.Library()
register.tag("cotton_component", cotton_component)
register.tag("cotton_slot", cotton_slot)
register.tag("cotton_props", cotton_props)
register.tag("cotton_props_frame", cotton_props_frame)
@register.filter
def merge(attrs, args):
# attrs is expected to be a dictionary of existing attributes
# args is a string of additional attributes to merge, e.g., "class:extra-class"
for arg in args.split(","):
key, value = arg.split(":", 1)
if key in attrs:
attrs[key] = value + " " + attrs[key]
else:
attrs[key] = value
return format_html_join(" ", '{0}="{1}"', attrs.items())

View file

View file

@ -0,0 +1,111 @@
from django.test import TestCase
from django_cotton.tests.utils import get_compiled, get_rendered
class CottonTestCase(TestCase):
def test_parent_component_is_rendered(self):
response = self.client.get("/parent")
self.assertContains(response, '<div class="i-am-parent">')
def test_child_is_rendered(self):
response = self.client.get("/child")
self.assertContains(response, '<div class="i-am-parent">')
self.assertContains(response, '<div class="i-am-child">')
def test_self_closing_is_rendered(self):
response = self.client.get("/self-closing")
self.assertContains(response, '<div class="i-am-parent">')
def test_named_slots_correctly_display_in_loop(self):
response = self.client.get("/named-slot-in-loop")
self.assertContains(response, "item name: Item 1")
self.assertContains(response, "item name: Item 2")
self.assertContains(response, "item name: Item 3")
def test_attribute_passing(self):
response = self.client.get("/attribute-passing")
self.assertContains(
response, '<div and-another="woo1" attribute_1="hello" thirdforluck="yes">'
)
def test_attribute_merging(self):
response = self.client.get("/attribute-merging")
self.assertContains(
response, 'class="form-group another-class-with:colon extra-class"'
)
def test_django_syntax_decoding(self):
response = self.client.get("/django-syntax-decoding")
self.assertContains(response, "some-class")
def test_props_are_converted_to_props_frame_tags(self):
compiled = get_compiled(
"""
<c-props prop1="string with space" />
content
"""
)
self.assertEquals(
compiled,
"""{% cotton_props_frame prop1=prop1|default:"string with space" %}content{% endcotton_props_frame %}""",
)
def test_attrs_do_not_contain_props(self):
response = self.client.get("/props-test")
self.assertContains(response, "attr1: 'im an attr'")
self.assertContains(response, "prop1: 'im a prop'")
self.assertContains(response, """attrs: 'attr1="im an attr"'""")
def test_strings_with_spaces_can_be_passed(self):
response = self.client.get("/string-with-spaces")
self.assertContains(response, "attr1: 'I have spaces'")
self.assertContains(response, "prop1: 'string with space'")
self.assertContains(response, "default_prop: 'default prop'")
self.assertContains(response, "named_slot: '")
self.assertContains(response, "named_slot with spaces")
self.assertContains(response, """attrs: 'attr1="I have spaces"'""")
def test_named_slots_dont_bleed_into_sibling_components(self):
html = """
<c-test-component>
component1
<c-slot name="named_slot">named slot 1</c-slot>
</c-test-component>
<c-test-component>
component2
</c-test-component>
"""
rendered = get_rendered(html)
self.assertTrue("named_slot: 'named slot 1'" in rendered)
self.assertTrue("named_slot: ''" in rendered)
def test_template_variables_are_not_parsed(self):
html = """
<c-test-component attr1="variable" :attr2="variable">
<c-slot name="named_slot">
<a href="#" silica:click.prevent="variable = 'lineage'">test</a>
</c-slot>
</c-test-component>
"""
rendered = get_rendered(html, {"variable": 1})
self.assertTrue("attr1: 'variable'" in rendered)
self.assertTrue("attr2: '1'" in rendered)
def test_int_attributes(self):
pass
def test_none_attributes(self):
pass
def test_list_attributes(self):
pass
def quotes_inside_quotes(self):
pass

View file

@ -0,0 +1,18 @@
from django.template import Context, Template
from django_cotton.cotton_loader import Loader as CottonLoader
def get_compiled(template_string):
return CottonLoader(engine=None)._compile_template_from_string(
template_string, component_key="test_key"
)
def get_rendered(template_string, context: dict = None):
if context is None:
context = {}
compiled_string = get_compiled(template_string)
return Template(compiled_string).render(Context(context))

45
django_cotton/urls.py Normal file
View file

@ -0,0 +1,45 @@
from django.views.generic import TemplateView
from . import views
from django.urls import path
app_name = "django_cotton"
class NamedSlotInLoop(TemplateView):
template_name = "named_slot_in_loop.cotton.html"
def get_context_data(self, **kwargs):
return {
"items": [
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
]
}
urlpatterns = [
path("parent", TemplateView.as_view(template_name="parent_test.cotton.html")),
path("child", TemplateView.as_view(template_name="child_test.cotton.html")),
path(
"self-closing",
TemplateView.as_view(template_name="self_closing_test.cotton.html"),
),
path("include", TemplateView.as_view(template_name="cotton_include.cotton.html")),
path("playground", TemplateView.as_view(template_name="playground.cotton.html")),
path("tag", TemplateView.as_view(template_name="tag.cotton.html")),
path("named-slot-in-loop", NamedSlotInLoop.as_view()),
path("test/compiled-cotton", views.compiled_cotton_test_view),
path("test/cotton", views.cotton_test_view),
path("test/native-extends", views.native_extends_test_view),
path("test/native-include", views.native_include_test_view),
path("attribute-merging", views.attribute_merging_test_view),
path("attribute-passing", views.attribute_passing_test_view),
path("django-syntax-decoding", views.django_syntax_decoding_test_view),
path(
"string-with-spaces",
TemplateView.as_view(template_name="string_with_spaces.cotton.html"),
),
path("props-test", TemplateView.as_view(template_name="props_test.cotton.html")),
path("variable-parsing", views.variable_parsing_test_view),
]

40
django_cotton/views.py Normal file
View file

@ -0,0 +1,40 @@
from django.shortcuts import render
# benchmark tests
def compiled_cotton_test_view(request):
return render(request, "compiled_cotton_test.html")
def cotton_test_view(request):
return render(request, "cotton_test.cotton.html")
def native_extends_test_view(request):
return render(request, "native_extends_test.html")
def native_include_test_view(request):
return render(request, "native_include_test.html")
# Django tests
def attribute_merging_test_view(request):
return render(request, "attribute_merging_test.cotton.html")
def attribute_passing_test_view(request):
return render(request, "attribute_passing_test.cotton.html")
def django_syntax_decoding_test_view(request):
return render(request, "django_syntax_decoding_test.cotton.html")
def variable_parsing_test_view(request):
return render(
request, "variable_parsing_test.cotton.html", {"variable": "some-class"}
)

16
django_cotton/wsgi.py Normal file
View file

@ -0,0 +1,16 @@
"""
WSGI config for django_cotton project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
application = get_wsgi_application()

39
docs/docker/Dockerfile Normal file
View file

@ -0,0 +1,39 @@
ARG PLATFORM=linux/amd64
# ---- Tailwind Build Stage (build is quicker like this vs installing npm, node separately) ----
FROM node:16-slim AS build_tailwind
WORKDIR /css
COPY package*.json ./
RUN npm install
COPY . .
RUN ["npx", "tailwindcss", "-o", "./docs_project/static/app.css"]
# Use an official Python runtime as a base image
FROM python:3.9-slim
# Keep logs unbuffered
ENV PYTHONUNBUFFERED 1
# Set the working directory in the container
WORKDIR /app
# Install Poetry
RUN pip install --upgrade pip \
&& pip install poetry
RUN apt-get update && \
apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Copy only dependencies definition to the docker image
COPY . /app/
# Install project dependencies
RUN poetry config virtualenvs.create false \
&& poetry install
RUN SECRET_KEY=dummy STATIC_URL='/staticfiles/' python manage.py collectstatic --noinput --verbosity 2
CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ]

16
docs/docker/bin/build.sh Executable file
View file

@ -0,0 +1,16 @@
#!/bin/bash
# if arg exists 'mac', then build for mac
if [ "$1" = "mac" ]; then
PLATFORM="linux/arm64"
else
PLATFORM="linux/amd64"
fi
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR
docker build --no-cache --build-arg PLATFORM=$PLATFORM -f ../Dockerfile ../../docs_project -t cotton-docs-app

4
docs/docker/bin/manage.sh Executable file
View file

@ -0,0 +1,4 @@
#!/usr/bin/env bash
# Start a new login shell to preserve history and execute the Docker command
bash -l -c "docker exec -it cotton-docs-web python manage.py $*"

9
docs/docker/bin/run-dev.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR
docker compose -f ../docker-compose.yaml up "$@"

9
docs/docker/bin/shell.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR/..
docker exec -it cotton-docs-web python manage.py shell

9
docs/docker/bin/stop.sh Executable file
View file

@ -0,0 +1,9 @@
#!/usr/bin/env bash
# Determine script directory
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
# Change directory to project directory
cd $SCRIPT_DIR/..
docker compose -f etc/docker/docker-compose.yaml -f etc/docker/docker-compose.dev.yaml down $*

3
docs/docker/bin/terminal.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker exec -it cotton-docs-web bash

3
docs/docker/bin/test.sh Executable file
View file

@ -0,0 +1,3 @@
#!/usr/bin/env bash
docker exec -t cotton-docs-web python manage.py test $*

View file

@ -0,0 +1,28 @@
version: '3'
services:
cotton-docs-web:
container_name: cotton-docs-web
restart: always
image: cotton-docs-app
working_dir: /app
command: python manage.py runserver 0.0.0.0:8000
environment:
- DEBUG=True
volumes:
- ../docs_project:/app
- ../../django_cotton:/app/django_cotton
ports:
- 8002:8000
cotton-docs-tailwind:
container_name: cotton-docs-tailwind
stop_signal: SIGINT
image: node:16
working_dir: /app
tty: true
volumes:
- ../docs_project:/app
# call the tailwind build before the watch
# command: /bin/sh -c "npm install && npx tailwindcss -c ./tailwind.config.js -o ./luma/static/app.css && npx tailwindcss -c ./tailwind.config.js -o ./luma/static/app.css --watch"
command: /bin/sh -c "trap 'exit' INT; npm install && npx tailwindcss -c ./tailwind.config.js -o ./docs_project/static/app.css && npx tailwindcss -c ./tailwind.config.js -o ./docs_project/static/app.css --watch"

View file

@ -0,0 +1,45 @@
ARG PLATFORM=linux/amd64
# ---- Tailwind Build Stage (build is quicker like this vs installing npm, node separately) ----
FROM node:16-slim AS build_tailwind
WORKDIR /css
COPY package*.json ./
RUN npm install
COPY . .
RUN ["npx", "tailwindcss", "-o", "./docs_project/static/app.css"]
# Use an official Python runtime as a base image
FROM python:3.9-slim
# Keep logs unbuffered
ENV PYTHONUNBUFFERED 1
# Set the working directory in the container
WORKDIR /app
# Install Poetry
RUN pip install --upgrade pip \
&& pip install poetry
RUN apt-get update && \
apt-get install -y --no-install-recommends \
chromium \
chromium-driver \
&& rm -rf /var/lib/apt/lists/*
# Copy only dependencies definition to the docker image
COPY . /app/
# Copy static files from the build_tailwind stage
COPY --from=build_tailwind /css/docs_project/static/app.css ./docs_project/static/app.css
# Install project dependencies
RUN poetry config virtualenvs.create false \
&& poetry install
RUN SECRET_KEY=dummy STATIC_URL='/staticfiles/' python manage.py collectstatic --noinput --verbosity 2
EXPOSE 8000
ENV PORT 8000
CMD ["gunicorn", "docs_project.wsgi:application", "--bind", "0.0.0.0:8000"]

139
docs/docs_project/README.md Normal file
View file

@ -0,0 +1,139 @@
# Cotton
Bringing component-based design to Django templates.
<a href="https://www.django-cotton.com" target="_blank">Document site</a>
## Overview
Cotton enhances Django templates by allowing component-based design, making UI composition more efficient and reusable. It integrates seamlessly with Tailwind CSS and retains full compatibility with native Django template features.
## Key Features
- **Rapid UI Composition:** Efficiently compose and reuse UI components.
- **Tailwind CSS Harmony:** Integrates with Tailwind's utility-first approach.
- **Interoperable with Django:** Enhances Django templates without replacing them.
- **Semantic Syntax:** HTML-like syntax for better code editor support.
- **Minimal Overhead:** Compiles to native Django components with automatic caching.
## Getting Started
### Installation
To install Cotton, run the following command:
```bash
pip install django-cotton
```
Then update your `settings.py`:
```python
INSTALLED_APPS = [
...
'django_cotton',
]
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': ['your_project/templates'],
'APP_DIRS': False,
'OPTIONS': {
'loaders': [
'django_cotton.template.loaders.CottonLoader',
# continue with default loaders:
# "django.template.loaders.filesystem.Loader",
# "django.template.loaders.app_directories.Loader",
],
'builtins': [
'django_cotton.templatetags.cotton',
],
},
},
]
```
### Quickstart
Create a new directory in your templates directory called `cotton`. Inside this directory, create a new file called `card.cotton.html` with the following content:
```html
<div class="bg-white shadow rounded border p-4">
<h2>{{ title }}</h2>
<p>{{ slot }}</p>
<button href="{% url url %}">Read more</button>
</div>
```
Create a view with a template. Views that contain Cotton components must also use the `.cotton.html` extension:
```python
# views.py
def dashboard_view(request):
return render(request, "dashboard.cotton.html")
```
```html
<!-- templates/dashboard.cotton.html -->
<c-card title="Trees" url="trees">
We have the best trees
</c-card>
<c-card title="Spades" url="spades">
The best spades in the land
</c-card>
```
### Usage Basics
- **Template Extensions:** View templates including Cotton components should use the `.cotton.html` extension.
- **Component Placement:** Components should be placed in the `templates/cotton` folder.
- **Naming Conventions:**
- Component filenames use snake_case: `my_component.cotton.html`
- Components are called using kebab-case: `<c-my-component />`
### Example
A minimal example using Cotton components:
```html
<!-- my_component.cotton.html -->
{{ slot }}
<!-- my_view.cotton.html -->
<c-my-component>
<p>Some content</p>
</c-my-component>
```
### Attributes and Slots
Components can accept attributes and named slots for flexible content and behavior customization:
```html
<!-- weather.cotton.html -->
<p>It's {{ temperature }}<sup>{{ unit }}</sup> and the condition is {{ condition }}.</p>
<!-- view.cotton.html -->
<c-weather temperature="23" unit="c" condition="windy"></c-weather>
```
#### Passing Variables
To pass a variable from the parent's context, prepend the attribute with a `:`.
```html
<!-- view.cotton.html -->
<c-weather :unit="unit"></c-weather>
```
#### Named Slots
```html
<!-- weather_card.cotton.html -->
<div class="flex ...">
<h2>{{ day }}:</h2> {{ icon }} {{ label }}
</div>
<!-- view.cotton.html -->
<c-weather-card day="Tuesday">
<c-slot name="icon">
<svg>...</svg>
</c-slot>
<c-slot name="label">
<h2 class="text-yellow-500">Sunny</h2>
</c-slot>
</c-weather-card>
```

View file

@ -0,0 +1 @@
_with_prop_prefix = "cotton_with_prop_"

View file

@ -0,0 +1,60 @@
from django.apps import AppConfig
from django.conf import settings
from django.template import engines
# fmt: off
class CottonConfig(AppConfig):
name = "django_cotton"
verbose_name = "Cotton"
def ready(self):
self._modify_templates_settings()
self._add_builtin_template_tag()
def _modify_templates_settings(self):
modified = False
for template in settings.TEMPLATES:
if not template.get("APP_DIRS", True):
# If APP_DIRS is explicitly set to False, we assume the user
# has a custom setup and do not modify the settings and provide tutorial instead.
continue
# Example modification: Set APP_DIRS to False
template["APP_DIRS"] = False
# Add your custom template loader
if "OPTIONS" not in template:
template["OPTIONS"] = {}
if "loaders" not in template["OPTIONS"]:
template["OPTIONS"]["loaders"] = []
# Add django_cotton loader
if "django_cotton.cotton_loader.Loader" not in template["OPTIONS"]["loaders"]:
template["OPTIONS"]["loaders"].insert(
0, "django_cotton.cotton_loader.Loader"
)
# Ensure default loaders are present, then add your custom loader
if "django.template.loaders.filesystem.Loader" not in template["OPTIONS"]["loaders"]:
template["OPTIONS"]["loaders"].append(
"django.template.loaders.filesystem.Loader"
)
if "django.template.loaders.app_directories.Loader" not in template["OPTIONS"]["loaders"]:
template["OPTIONS"]["loaders"].append(
"django.template.loaders.app_directories.Loader"
)
# Specify TEMPLATE_DIRS if necessary
# template['DIRS'] += ['path/to/your/templates']
modified = True
if modified:
print("TEMPLATES setting modified by Django Cotton.")
def _add_builtin_template_tag(self):
"""Add a custom template tag to the built-ins."""
builtins = engines["django"].engine.builtins
custom_tag_lib = "django_cotton.templatetags.cotton"
if custom_tag_lib not in builtins:
builtins.append(custom_tag_lib)

View file

@ -0,0 +1,291 @@
import os
import re
import hashlib
import warnings
from django.template.loaders.base import Loader as BaseLoader
from django.core.exceptions import SuspiciousFileOperation
from django.template import TemplateDoesNotExist
from django.utils._os import safe_join
from django.template import Template
from django.core.cache import cache
from django.template import Origin
from django.conf import settings
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
class Loader(BaseLoader):
is_usable = True
def __init__(self, engine, dirs=None):
super().__init__(engine)
self.cache_handler = CottonTemplateCacheHandler()
self.dirs = dirs
self.django_syntax_placeholders = []
def get_template_from_string(self, template_string):
"""Create and return a Template object from a string. Used primarily for testing."""
return Template(template_string, engine=self.engine)
def get_contents(self, origin):
# check if file exists, whilst getting the mtime for cache key
try:
mtime = os.path.getmtime(origin.name)
except FileNotFoundError:
raise TemplateDoesNotExist(origin)
# check and return cached template
cache_key = self.cache_handler.get_cache_key(origin.template_name, mtime)
cached_content = self.cache_handler.get_cached_template(cache_key)
if cached_content is not None:
return cached_content
# If not cached, process the template
template_string = self._get_template_string(origin.name)
# We need to provide a key to the current view or component (in this case, view) so that we can namespace
# slot data, preventing bleeding and ensure component's clear only data in the context applicable to itself
# in this case, we're top level, likely in a view so we use the view template name as the key
component_key = (
origin.template_name.lstrip("cotton/")
.rstrip(".cotton.html")
.replace("/", ".")
)
compiled_template = self._compile_template_from_string(
template_string, component_key
)
# Cache the processed template
self.cache_handler.cache_template(cache_key, compiled_template)
return compiled_template
def _replace_syntax_with_placeholders(self, content):
"""# replace {% ... %} and {{ ... }} with placeholders so they dont get touched
or encoded by bs4. Store them to later switch them back in after transformation.
"""
self.django_syntax_placeholders = []
# First handle cotton_verbatim blocks, this is designed to preserve and display cotton syntax,
# akin to the verbatim tag in Django.
def replace_cotton_verbatim(match):
inner_content = match.group(
1
) # Get the inner content without the cotton_verbatim tags
self.django_syntax_placeholders.append(inner_content)
return f"__django_syntax__{len(self.django_syntax_placeholders)}__"
# Replace cotton_verbatim blocks, capturing inner content
content = re.sub(
r"\{% cotton_verbatim %\}(.*?)\{% endcotton_verbatim %\}",
replace_cotton_verbatim,
content,
flags=re.DOTALL,
)
content = re.sub(
r"\{%.*?%\}",
lambda x: self.django_syntax_placeholders.append(x.group(0))
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
content,
)
content = re.sub(
r"\{\{.*?\}\}",
lambda x: self.django_syntax_placeholders.append(x.group(0))
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
content,
)
return content
def _replace_placeholders_with_syntax(self, content):
"""After modifying the content, replace the placeholders with the django template tags and variables."""
for i, placeholder in enumerate(self.django_syntax_placeholders, 1):
content = content.replace(f"__django_syntax__{i}__", placeholder)
return content
def _get_template_string(self, template_name):
try:
with open(template_name, "r") as f:
content = f.read()
except FileNotFoundError:
raise TemplateDoesNotExist(template_name)
return content
def _compile_template_from_string(self, content, component_key):
content = self._replace_syntax_with_placeholders(content)
content = self._compile_cotton_to_django(content, component_key)
content = self._replace_placeholders_with_syntax(content)
content = self._revert_bs4_attribute_empty_attribute_fixing(content)
return content
def _revert_bs4_attribute_empty_attribute_fixing(self, contents):
"""Django's template parser adds ="" to empty attribute-like parts in any html-like node, i.e. <div {{ something }}> gets
compiled to <div {{ something }}=""> Then if 'something' is holding attributes sets, the last attribute value is
not quoted. i.e. model=test not model="test"."""
cleaned_content = re.sub('}}=""', "}}", contents)
return cleaned_content
def get_dirs(self):
return self.dirs if self.dirs is not None else self.engine.dirs
def get_template_sources(self, template_name):
"""Return an Origin object pointing to an absolute path in each directory
in template_dirs. For security reasons, if a path doesn't lie inside
one of the template_dirs it is excluded from the result set."""
if template_name.endswith(".cotton.html"):
for template_dir in self.get_dirs():
try:
name = safe_join(template_dir, template_name)
except SuspiciousFileOperation:
# The joined path was located outside of this template_dir
# (it might be inside another one, so this isn't fatal).
continue
yield Origin(
name=name,
template_name=template_name,
loader=self,
)
def _compile_cotton_to_django(self, html_content, component_key):
"""Convert cotton <c-* syntax to {%."""
soup = BeautifulSoup(html_content, "html.parser")
soup = self._wrap_with_cotton_props_frame(soup)
self._transform_components(soup, component_key)
return str(soup)
def _transform_prop_tags(self, soup):
c_props = soup.find_all("c-props")
for tag in c_props:
# Build the cotton_props tag string
props_list = []
for prop, value in tag.attrs.items():
if value is None:
props_list.append(prop)
else:
props_list.append(f'{prop}="{value}"')
cotton_props_str = "{% cotton_props " + " ".join(props_list) + " %}"
# Replace the <c-props> tag with the cotton_props string
tag.replace_with(cotton_props_str)
return soup
def _wrap_with_cotton_props_frame(self, soup):
"""Wrap content with {% cotton_props_frame %} to be able to govern props and attributes. In order to recognise
props defined in a component and also have them available in context, we wrap the entire contents in another
component: cotton_props_frame."""
props_with_defaults = []
c_props = soup.find("c-props")
# parse c-props tag to extract properties and defaults
if c_props:
props_with_defaults = []
for prop, value in c_props.attrs.items():
if value is None:
props_with_defaults.append(f"{prop}={prop}")
else:
# Assuming value is already a string that represents the default value
props_with_defaults.append(f'{prop}={prop}|default:"{value}"')
c_props.decompose()
# Construct the {% with %} opening tag
opening = "{% cotton_props_frame " + " ".join(props_with_defaults) + " %}"
closing = "{% endcotton_props_frame %}"
# Convert the remaining soup back to a string and wrap it within {% with %} block
wrapped_content = opening + str(soup).strip() + closing
# Since we can't replace the soup object itself, we create new soup instead
new_soup = BeautifulSoup(wrapped_content, "html.parser")
return new_soup
def _transform_named_slot(self, slot_tag, component_key):
"""Replace <c-slot> tags with the {% cotton_slot %} template tag"""
# for c_slot in soup.find_all("c-slot"):
slot_name = slot_tag.get("name", "").strip()
inner_html = "".join(str(content) for content in slot_tag.contents)
# Check and process any components in the slot content
slot_soup = BeautifulSoup(inner_html, "html.parser")
self._transform_components(slot_soup, component_key)
cotton_slot_tag = f"{{% cotton_slot {slot_name} {component_key} %}}{str(slot_soup)}{{% end_cotton_slot %}}"
slot_tag.replace_with(BeautifulSoup(cotton_slot_tag, "html.parser"))
def _transform_components(self, soup, component_key):
"""Replace <c-[component path]> tags with the {% cotton_component %} template tag"""
for tag in soup.find_all(re.compile("^c-"), recursive=True):
if tag.name == "c-slot":
self._transform_named_slot(tag, component_key)
continue
component_name = tag.name[2:]
# Convert dot notation to path structure and replace hyphens with underscores
path = component_name.replace(".", "/").replace("-", "_")
# Construct the opening tag
opening_tag = f"{{% cotton_component {'cotton/{}.cotton.html'.format(path)} {component_name} "
for attr, value in tag.attrs.items():
if attr == "class":
value = " ".join(value)
opening_tag += ' {}="{}"'.format(attr, value)
opening_tag += " %}"
# Construct the closing tag
closing_tag = "{% end_cotton_component %}"
if tag.contents:
tag_soup = BeautifulSoup(tag.decode_contents(), "html.parser")
self._transform_components(tag_soup, component_name)
# Create new content with opening tag, tag content, and closing tag
new_content = opening_tag + str(tag_soup) + closing_tag
else:
# Create new content with opening tag and closing tag
new_content = opening_tag + closing_tag
# Replace the original tag with the new content
new_soup = BeautifulSoup(new_content, "html.parser")
tag.replace_with(new_soup)
return soup
class CottonTemplateCacheHandler:
"""Handles caching of cotton templates so the html parsing is only done on first load of each view or component."""
def __init__(self):
self.enabled = getattr(settings, "TEMPLATE_CACHING_ENABLED", True)
def get_cache_key(self, template_name, mtime):
template_hash = hashlib.sha256(template_name.encode()).hexdigest()
return f"cotton_cache_{template_hash}_{mtime}"
def get_cached_template(self, cache_key):
if not self.enabled:
return None
return cache.get(cache_key)
def cache_template(self, cache_key, content, timeout=None):
if self.enabled:
cache.set(cache_key, content, timeout=timeout)

View file

@ -0,0 +1,3 @@
<c-merges-attributes class="extra-class" silica:model="test" another="test">
ss
</c-merges-attributes>

View file

@ -0,0 +1,3 @@
<c-receives-attributes attribute_1="hello" and-another="woo1" thirdForLuck="yes">
ss
</c-receives-attributes>

View file

@ -0,0 +1,3 @@
<c-parent>
<c-child>d</c-child>
</c-parent>

View file

@ -0,0 +1 @@
<div class="i-am-child"></div>

View file

@ -0,0 +1,11 @@
<div>
Header:
{{ header }}
</div>
<div>
Content:
{{ slot }}
</div>

View file

@ -0,0 +1,3 @@
<div {{ attrs_dict|merge:'class:form-group another-class-with:colon' }}>
</div>

View file

@ -0,0 +1,3 @@
<div>
{{ name }}
</div>

View file

@ -0,0 +1,3 @@
<div class="i-am-parent">
{{slot}}
</div>

View file

@ -0,0 +1,3 @@
<div {{ attrs }}>
</div>

View file

@ -0,0 +1,13 @@
<c-props prop1 default_prop="default prop" />
<p>slot: '{{ slot }}'</p>
<p>attr1: '{{ attr1 }}'</p>
<p>attr2: '{{ attr2 }}'</p>
<p>prop1: '{{ prop1 }}'</p>
<p>default_prop: '{{ default_prop }}'</p>
<p>named_slot: '{{ named_slot }}'</p>
<p>attrs: '{{ attrs }}'</p>

View file

@ -0,0 +1 @@
<div class="{% if 1 < 2 %} some-class {% endif %}">Hello, World!</div>

View file

@ -0,0 +1,3 @@
<c-parent>
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
</c-parent>

View file

@ -0,0 +1,7 @@
{% for item in items %}
<c-named-slot-component>
<c-slot name="name">
item name: {{ item.name }}
</c-slot>
</c-named-slot-component>
{% endfor %}

View file

@ -0,0 +1 @@
<c-parent></c-parent>

View file

@ -0,0 +1,4 @@
{% load static %}
<c-parent/>

View file

@ -0,0 +1,5 @@
<c-test-component prop1="string with space" attr1="I have spaces">
<c-slot name="named_slot">
named_slot with spaces
</c-slot>
</c-test-component>

View file

@ -0,0 +1,2 @@
<c-test-component attr1="variable" :attr2="variable">
</c-test-component>

View file

@ -0,0 +1,68 @@
from django import template
from django.template import Node
from django.template.loader import render_to_string
def cotton_component(parser, token):
bits = token.split_contents()
tag_name = bits[0]
template_path = bits[1]
component_key = bits[2]
kwargs = {}
for bit in bits[3:]:
key, value = bit.split("=")
if key.startswith(":"): # Detect variables
key = key[1:] # Remove ':' prefix
value = value.strip("'\"") # Remove quotes
kwargs[key] = template.Variable(value) # Treat as a variable
else:
kwargs[key] = value.strip("'\"") # Treat as a literal string
nodelist = parser.parse(("end_cotton_component",))
parser.delete_first_token()
return CottonComponentNode(nodelist, template_path, component_key, kwargs)
class CottonComponentNode(Node):
def __init__(self, nodelist, template_path, component_key, kwargs):
self.nodelist = nodelist
self.template_path = template_path
self.component_key = component_key
self.kwargs = kwargs
def render(self, context):
local_context = context.flatten()
attrs = {}
for key, value in self.kwargs.items():
if isinstance(value, template.Variable): # Resolve variables
try:
resolved_value = value.resolve(context)
attrs[key] = resolved_value
except template.VariableDoesNotExist:
pass # Handle variable not found, if necessary
else:
attrs[key] = value # Use literal string
# Add the remainder as the default slot
rendered = self.nodelist.render(context)
local_context.update({"slot": rendered})
slots = context.get("cotton_slots", {})
component_slots = slots.get(self.component_key, {})
local_context.update(component_slots)
local_context.update(attrs)
local_context.update({"attrs_dict": attrs})
rendered = render_to_string(self.template_path, local_context)
# Now reset the component's slots in context to prevent bleeding
if self.component_key in slots:
slots[self.component_key] = {}
context.update({"cotton_slots": slots})
return rendered

Some files were not shown because too many files have changed in this diff Show more