diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml
new file mode 100644
index 0000000..cf3b7af
--- /dev/null
+++ b/.github/workflows/deploy_docs.yml
@@ -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 }}
\ No newline at end of file
diff --git a/.github/workflows/publish_to_pypi.yml b/.github/workflows/publish_to_pypi.yml
new file mode 100644
index 0000000..a7a8025
--- /dev/null
+++ b/.github/workflows/publish_to_pypi.yml
@@ -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 }}
\ No newline at end of file
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 0000000..a220059
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -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
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..52e38e2
--- /dev/null
+++ b/.gitignore
@@ -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
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b4003bf
--- /dev/null
+++ b/LICENSE
@@ -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.
diff --git a/README.md b/README.md
index 90c1dac..7a7cdf7 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,139 @@
-# cotton
-Bringing component based design to Django templates
+# Cotton
+
+Bringing component-based design to Django templates.
+
+Document site
+
+## 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
+
+
{{ title }}
+
{{ slot }}
+
+
+```
+
+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
+
+
+ We have the best trees
+
+
+
+ The best spades in the land
+
+```
+
+### 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: ``
+
+### Example
+A minimal example using Cotton components:
+
+```html
+
+{{ slot }}
+
+
+
+
Some content
+
+```
+
+### Attributes and Slots
+Components can accept attributes and named slots for flexible content and behavior customization:
+
+```html
+
+
It's {{ temperature }}{{ unit }} and the condition is {{ condition }}.
+
+
+
+```
+
+#### Passing Variables
+To pass a variable from the parent's context, prepend the attribute with a `:`.
+
+```html
+
+
+```
+
+#### Named Slots
+```html
+
+
+
{{ day }}:
{{ icon }} {{ label }}
+
+
+
+
+
+
+
+
+
Sunny
+
+
+```
\ No newline at end of file
diff --git a/dev/docker/Dockerfile b/dev/docker/Dockerfile
new file mode 100644
index 0000000..af762ed
--- /dev/null
+++ b/dev/docker/Dockerfile
@@ -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" ]
\ No newline at end of file
diff --git a/dev/docker/bin/build.sh b/dev/docker/bin/build.sh
new file mode 100755
index 0000000..f4576f1
--- /dev/null
+++ b/dev/docker/bin/build.sh
@@ -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
\ No newline at end of file
diff --git a/dev/docker/bin/manage.sh b/dev/docker/bin/manage.sh
new file mode 100755
index 0000000..5654dfd
--- /dev/null
+++ b/dev/docker/bin/manage.sh
@@ -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 $*"
\ No newline at end of file
diff --git a/dev/docker/bin/run-dev.sh b/dev/docker/bin/run-dev.sh
new file mode 100755
index 0000000..d592c93
--- /dev/null
+++ b/dev/docker/bin/run-dev.sh
@@ -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 "$@"
diff --git a/dev/docker/bin/shell.sh b/dev/docker/bin/shell.sh
new file mode 100755
index 0000000..2cf7331
--- /dev/null
+++ b/dev/docker/bin/shell.sh
@@ -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
\ No newline at end of file
diff --git a/dev/docker/bin/stop.sh b/dev/docker/bin/stop.sh
new file mode 100755
index 0000000..4d46178
--- /dev/null
+++ b/dev/docker/bin/stop.sh
@@ -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 $*
\ No newline at end of file
diff --git a/dev/docker/bin/terminal.sh b/dev/docker/bin/terminal.sh
new file mode 100755
index 0000000..f67f975
--- /dev/null
+++ b/dev/docker/bin/terminal.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+docker exec -it cotton-dev-app bash
\ No newline at end of file
diff --git a/dev/docker/bin/test.sh b/dev/docker/bin/test.sh
new file mode 100755
index 0000000..e16436b
--- /dev/null
+++ b/dev/docker/bin/test.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+docker exec -t cotton-dev-app python manage.py test $*
diff --git a/dev/docker/docker-compose.yaml b/dev/docker/docker-compose.yaml
new file mode 100644
index 0000000..e5fc9b9
--- /dev/null
+++ b/dev/docker/docker-compose.yaml
@@ -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
diff --git a/dev/example_project/example_project/__init__.py b/dev/example_project/example_project/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dev/example_project/example_project/asgi.py b/dev/example_project/example_project/asgi.py
new file mode 100644
index 0000000..fa0e5e9
--- /dev/null
+++ b/dev/example_project/example_project/asgi.py
@@ -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()
diff --git a/dev/example_project/example_project/settings.py b/dev/example_project/example_project/settings.py
new file mode 100644
index 0000000..f9760bd
--- /dev/null
+++ b/dev/example_project/example_project/settings.py
@@ -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
diff --git a/dev/example_project/example_project/templates/child_test.cotton.html b/dev/example_project/example_project/templates/child_test.cotton.html
new file mode 100644
index 0000000..ec03be8
--- /dev/null
+++ b/dev/example_project/example_project/templates/child_test.cotton.html
@@ -0,0 +1,3 @@
+
+ d
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/benchmarks/cotton.cotton.html b/dev/example_project/example_project/templates/cotton/benchmarks/cotton.cotton.html
new file mode 100644
index 0000000..57996ab
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/benchmarks/cotton.cotton.html
@@ -0,0 +1,9 @@
+
+ I'm default
+
+ I'm top
+
+
+ I'm bottom
+
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/benchmarks/cotton_compiled.html b/dev/example_project/example_project/templates/cotton/benchmarks/cotton_compiled.html
new file mode 100644
index 0000000..fa0c4e3
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/benchmarks/cotton_compiled.html
@@ -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 %}
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/benchmarks/native_extends.html b/dev/example_project/example_project/templates/cotton/benchmarks/native_extends.html
new file mode 100644
index 0000000..4c774a7
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/benchmarks/native_extends.html
@@ -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 %}
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/benchmarks/partials/main.cotton.html b/dev/example_project/example_project/templates/cotton/benchmarks/partials/main.cotton.html
new file mode 100644
index 0000000..03e5aaf
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/benchmarks/partials/main.cotton.html
@@ -0,0 +1,5 @@
+{{ top }}
+
+{{ slot }}
+
+{{ bottom }}
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/benchmarks/partials/native_main.html b/dev/example_project/example_project/templates/cotton/benchmarks/partials/native_main.html
new file mode 100644
index 0000000..b8def10
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/benchmarks/partials/native_main.html
@@ -0,0 +1,5 @@
+{% block top %}{% endblock %}
+
+{% block content %}{% endblock %}
+
+{% block bottom %}{% endblock %}
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/child.cotton.html b/dev/example_project/example_project/templates/cotton/child.cotton.html
new file mode 100644
index 0000000..9f989fa
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/child.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/named_slot_component.cotton.html b/dev/example_project/example_project/templates/cotton/named_slot_component.cotton.html
new file mode 100644
index 0000000..e324e2c
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/named_slot_component.cotton.html
@@ -0,0 +1,3 @@
+
+ {{ name }}
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/parent.cotton.html b/dev/example_project/example_project/templates/cotton/parent.cotton.html
new file mode 100644
index 0000000..c534580
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/parent.cotton.html
@@ -0,0 +1,3 @@
+
+ {{slot}}
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/cotton/props_test_component.cotton.html b/dev/example_project/example_project/templates/cotton/props_test_component.cotton.html
new file mode 100644
index 0000000..1005946
--- /dev/null
+++ b/dev/example_project/example_project/templates/cotton/props_test_component.cotton.html
@@ -0,0 +1,12 @@
+
+
+
+ {{ testy }}
+
prop1: '{{ prop1 }}'
+
attr1: '{{ attr1 }}'
+
empty_prop: '{{ empty_prop }}'
+
prop_with_default: '{{ prop_with_default }}'
+
slot: '{{ slot }}'
+
named_slot: '{{ named_slot }}'
+
attrs: '{{ attrs }}'
+
diff --git a/dev/example_project/example_project/templates/form_test.cotton.html b/dev/example_project/example_project/templates/form_test.cotton.html
new file mode 100644
index 0000000..6c70273
--- /dev/null
+++ b/dev/example_project/example_project/templates/form_test.cotton.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/named_slot_in_loop.cotton.html b/dev/example_project/example_project/templates/named_slot_in_loop.cotton.html
new file mode 100644
index 0000000..136d7c1
--- /dev/null
+++ b/dev/example_project/example_project/templates/named_slot_in_loop.cotton.html
@@ -0,0 +1,7 @@
+{% for item in items %}
+
+
+ item name: {{ item.name }}
+
+
+{% endfor %}
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/parent_test.cotton.html b/dev/example_project/example_project/templates/parent_test.cotton.html
new file mode 100644
index 0000000..9922300
--- /dev/null
+++ b/dev/example_project/example_project/templates/parent_test.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/props_test.cotton.html b/dev/example_project/example_project/templates/props_test.cotton.html
new file mode 100644
index 0000000..5f575c6
--- /dev/null
+++ b/dev/example_project/example_project/templates/props_test.cotton.html
@@ -0,0 +1,3 @@
+
+ default slot
+
\ No newline at end of file
diff --git a/dev/example_project/example_project/templates/self_closing_test.cotton.html b/dev/example_project/example_project/templates/self_closing_test.cotton.html
new file mode 100644
index 0000000..9c99f37
--- /dev/null
+++ b/dev/example_project/example_project/templates/self_closing_test.cotton.html
@@ -0,0 +1,4 @@
+{% load static %}
+
+
+
\ No newline at end of file
diff --git a/dev/example_project/manage.py b/dev/example_project/manage.py
new file mode 100755
index 0000000..0a41077
--- /dev/null
+++ b/dev/example_project/manage.py
@@ -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()
diff --git a/dev/example_project/pyproject.toml b/dev/example_project/pyproject.toml
new file mode 100644
index 0000000..761d3f3
--- /dev/null
+++ b/dev/example_project/pyproject.toml
@@ -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 "]
+
+[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"
\ No newline at end of file
diff --git a/dev/example_project/render_load_test.py b/dev/example_project/render_load_test.py
new file mode 100644
index 0000000..83e70d3
--- /dev/null
+++ b/dev/example_project/render_load_test.py
@@ -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")
diff --git a/django_cotton/__init__.py b/django_cotton/__init__.py
new file mode 100644
index 0000000..e262b60
--- /dev/null
+++ b/django_cotton/__init__.py
@@ -0,0 +1 @@
+_with_prop_prefix = "cotton_with_prop_"
diff --git a/django_cotton/cotton_loader.py b/django_cotton/cotton_loader.py
new file mode 100755
index 0000000..c192f82
--- /dev/null
+++ b/django_cotton/cotton_loader.py
@@ -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.
gets
+ compiled to
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 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 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 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)
diff --git a/django_cotton/templates/attribute_merging_test.cotton.html b/django_cotton/templates/attribute_merging_test.cotton.html
new file mode 100644
index 0000000..ebcf664
--- /dev/null
+++ b/django_cotton/templates/attribute_merging_test.cotton.html
@@ -0,0 +1,3 @@
+
+ ss
+
\ No newline at end of file
diff --git a/django_cotton/templates/attribute_passing_test.cotton.html b/django_cotton/templates/attribute_passing_test.cotton.html
new file mode 100644
index 0000000..a7bc9fc
--- /dev/null
+++ b/django_cotton/templates/attribute_passing_test.cotton.html
@@ -0,0 +1,3 @@
+
+ ss
+
\ No newline at end of file
diff --git a/django_cotton/templates/child_test.cotton.html b/django_cotton/templates/child_test.cotton.html
new file mode 100644
index 0000000..ec03be8
--- /dev/null
+++ b/django_cotton/templates/child_test.cotton.html
@@ -0,0 +1,3 @@
+
+ d
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/child.cotton.html b/django_cotton/templates/cotton/child.cotton.html
new file mode 100644
index 0000000..9f989fa
--- /dev/null
+++ b/django_cotton/templates/cotton/child.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/container.cotton.html b/django_cotton/templates/cotton/container.cotton.html
new file mode 100644
index 0000000..835e5c4
--- /dev/null
+++ b/django_cotton/templates/cotton/container.cotton.html
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/named_slot_component.cotton.html b/django_cotton/templates/cotton/named_slot_component.cotton.html
new file mode 100644
index 0000000..e324e2c
--- /dev/null
+++ b/django_cotton/templates/cotton/named_slot_component.cotton.html
@@ -0,0 +1,3 @@
+
+ {{ name }}
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/parent.cotton.html b/django_cotton/templates/cotton/parent.cotton.html
new file mode 100644
index 0000000..c534580
--- /dev/null
+++ b/django_cotton/templates/cotton/parent.cotton.html
@@ -0,0 +1,3 @@
+
+ {{slot}}
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/receives_attributes.cotton.html b/django_cotton/templates/cotton/receives_attributes.cotton.html
new file mode 100644
index 0000000..8d463b4
--- /dev/null
+++ b/django_cotton/templates/cotton/receives_attributes.cotton.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/django_cotton/templates/cotton/test_component.cotton.html b/django_cotton/templates/cotton/test_component.cotton.html
new file mode 100644
index 0000000..c1cddfe
--- /dev/null
+++ b/django_cotton/templates/cotton/test_component.cotton.html
@@ -0,0 +1,13 @@
+
+
+
slot: '{{ slot }}'
+
+
attr1: '{{ attr1 }}'
+
attr2: '{{ attr2 }}'
+
+
prop1: '{{ prop1 }}'
+
default_prop: '{{ default_prop }}'
+
+
named_slot: '{{ named_slot }}'
+
+
attrs: '{{ attrs }}'
diff --git a/django_cotton/templates/django_syntax_decoding_test.cotton.html b/django_cotton/templates/django_syntax_decoding_test.cotton.html
new file mode 100644
index 0000000..ee3d784
--- /dev/null
+++ b/django_cotton/templates/django_syntax_decoding_test.cotton.html
@@ -0,0 +1 @@
+
Hello, World!
\ No newline at end of file
diff --git a/django_cotton/templates/form_test.cotton.html b/django_cotton/templates/form_test.cotton.html
new file mode 100644
index 0000000..6c70273
--- /dev/null
+++ b/django_cotton/templates/form_test.cotton.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/django_cotton/templates/named_slot_in_loop.cotton.html b/django_cotton/templates/named_slot_in_loop.cotton.html
new file mode 100644
index 0000000..136d7c1
--- /dev/null
+++ b/django_cotton/templates/named_slot_in_loop.cotton.html
@@ -0,0 +1,7 @@
+{% for item in items %}
+
+
+ item name: {{ item.name }}
+
+
+{% endfor %}
\ No newline at end of file
diff --git a/django_cotton/templates/parent_test.cotton.html b/django_cotton/templates/parent_test.cotton.html
new file mode 100644
index 0000000..9922300
--- /dev/null
+++ b/django_cotton/templates/parent_test.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/django_cotton/templates/self_closing_test.cotton.html b/django_cotton/templates/self_closing_test.cotton.html
new file mode 100644
index 0000000..9c99f37
--- /dev/null
+++ b/django_cotton/templates/self_closing_test.cotton.html
@@ -0,0 +1,4 @@
+{% load static %}
+
+
+
\ No newline at end of file
diff --git a/django_cotton/templates/string_with_spaces.cotton.html b/django_cotton/templates/string_with_spaces.cotton.html
new file mode 100644
index 0000000..ab4ddb5
--- /dev/null
+++ b/django_cotton/templates/string_with_spaces.cotton.html
@@ -0,0 +1,5 @@
+
+
+ named_slot with spaces
+
+
\ No newline at end of file
diff --git a/django_cotton/templates/variable_parsing_test.cotton.html b/django_cotton/templates/variable_parsing_test.cotton.html
new file mode 100644
index 0000000..8402bd4
--- /dev/null
+++ b/django_cotton/templates/variable_parsing_test.cotton.html
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/django_cotton/templatetags/__init__.py b/django_cotton/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_cotton/templatetags/_component.py b/django_cotton/templatetags/_component.py
new file mode 100644
index 0000000..eaf365c
--- /dev/null
+++ b/django_cotton/templatetags/_component.py
@@ -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
diff --git a/django_cotton/templatetags/_props.py b/django_cotton/templatetags/_props.py
new file mode 100644
index 0000000..e5f2961
--- /dev/null
+++ b/django_cotton/templatetags/_props.py
@@ -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 ""
diff --git a/django_cotton/templatetags/_props_frame.py b/django_cotton/templatetags/_props_frame.py
new file mode 100644
index 0000000..e1c5492
--- /dev/null
+++ b/django_cotton/templatetags/_props_frame.py
@@ -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 () 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)
diff --git a/django_cotton/templatetags/_slot.py b/django_cotton/templatetags/_slot.py
new file mode 100644
index 0000000..e25ba70
--- /dev/null
+++ b/django_cotton/templatetags/_slot.py
@@ -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 ""
diff --git a/django_cotton/templatetags/cotton.py b/django_cotton/templatetags/cotton.py
new file mode 100644
index 0000000..43c9b17
--- /dev/null
+++ b/django_cotton/templatetags/cotton.py
@@ -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())
diff --git a/django_cotton/tests/__init__.py b/django_cotton/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/django_cotton/tests/test_cotton.py b/django_cotton/tests/test_cotton.py
new file mode 100644
index 0000000..aa5bafe
--- /dev/null
+++ b/django_cotton/tests/test_cotton.py
@@ -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, '
'
+ )
+
+ 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(
+ """
+
+
+ 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 = """
+
+ component1
+ named slot 1
+
+
+ component2
+
+ """
+
+ 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 = """
+
+
+ test
+
+
+ """
+
+ 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
diff --git a/django_cotton/tests/utils.py b/django_cotton/tests/utils.py
new file mode 100644
index 0000000..f7f9d73
--- /dev/null
+++ b/django_cotton/tests/utils.py
@@ -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))
diff --git a/django_cotton/urls.py b/django_cotton/urls.py
new file mode 100644
index 0000000..6990478
--- /dev/null
+++ b/django_cotton/urls.py
@@ -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),
+]
diff --git a/django_cotton/views.py b/django_cotton/views.py
new file mode 100644
index 0000000..c6c60ef
--- /dev/null
+++ b/django_cotton/views.py
@@ -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"}
+ )
diff --git a/django_cotton/wsgi.py b/django_cotton/wsgi.py
new file mode 100644
index 0000000..6462b4b
--- /dev/null
+++ b/django_cotton/wsgi.py
@@ -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()
diff --git a/docs/docker/Dockerfile b/docs/docker/Dockerfile
new file mode 100644
index 0000000..44768cc
--- /dev/null
+++ b/docs/docker/Dockerfile
@@ -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" ]
\ No newline at end of file
diff --git a/docs/docker/bin/build.sh b/docs/docker/bin/build.sh
new file mode 100755
index 0000000..e2598c8
--- /dev/null
+++ b/docs/docker/bin/build.sh
@@ -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
\ No newline at end of file
diff --git a/docs/docker/bin/manage.sh b/docs/docker/bin/manage.sh
new file mode 100755
index 0000000..bc907b2
--- /dev/null
+++ b/docs/docker/bin/manage.sh
@@ -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 $*"
\ No newline at end of file
diff --git a/docs/docker/bin/run-dev.sh b/docs/docker/bin/run-dev.sh
new file mode 100755
index 0000000..d592c93
--- /dev/null
+++ b/docs/docker/bin/run-dev.sh
@@ -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 "$@"
diff --git a/docs/docker/bin/shell.sh b/docs/docker/bin/shell.sh
new file mode 100755
index 0000000..98e0e1e
--- /dev/null
+++ b/docs/docker/bin/shell.sh
@@ -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
\ No newline at end of file
diff --git a/docs/docker/bin/stop.sh b/docs/docker/bin/stop.sh
new file mode 100755
index 0000000..4d46178
--- /dev/null
+++ b/docs/docker/bin/stop.sh
@@ -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 $*
\ No newline at end of file
diff --git a/docs/docker/bin/terminal.sh b/docs/docker/bin/terminal.sh
new file mode 100755
index 0000000..0904222
--- /dev/null
+++ b/docs/docker/bin/terminal.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+docker exec -it cotton-docs-web bash
\ No newline at end of file
diff --git a/docs/docker/bin/test.sh b/docs/docker/bin/test.sh
new file mode 100755
index 0000000..9af187e
--- /dev/null
+++ b/docs/docker/bin/test.sh
@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+docker exec -t cotton-docs-web python manage.py test $*
diff --git a/docs/docker/docker-compose.yaml b/docs/docker/docker-compose.yaml
new file mode 100644
index 0000000..f9c5431
--- /dev/null
+++ b/docs/docker/docker-compose.yaml
@@ -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"
diff --git a/docs/docs_project/DockerfileTmp b/docs/docs_project/DockerfileTmp
new file mode 100644
index 0000000..ce62662
--- /dev/null
+++ b/docs/docs_project/DockerfileTmp
@@ -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"]
\ No newline at end of file
diff --git a/docs/docs_project/README.md b/docs/docs_project/README.md
new file mode 100644
index 0000000..7a7cdf7
--- /dev/null
+++ b/docs/docs_project/README.md
@@ -0,0 +1,139 @@
+# Cotton
+
+Bringing component-based design to Django templates.
+
+Document site
+
+## 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
+
+
{{ title }}
+
{{ slot }}
+
+
+```
+
+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
+
+
+ We have the best trees
+
+
+
+ The best spades in the land
+
+```
+
+### 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: ``
+
+### Example
+A minimal example using Cotton components:
+
+```html
+
+{{ slot }}
+
+
+
+
Some content
+
+```
+
+### Attributes and Slots
+Components can accept attributes and named slots for flexible content and behavior customization:
+
+```html
+
+
It's {{ temperature }}{{ unit }} and the condition is {{ condition }}.
+
+
+
+```
+
+#### Passing Variables
+To pass a variable from the parent's context, prepend the attribute with a `:`.
+
+```html
+
+
+```
+
+#### Named Slots
+```html
+
+
+
{{ day }}:
{{ icon }} {{ label }}
+
+
+
+
+
+
+
+
+
Sunny
+
+
+```
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/__init__.py b/docs/docs_project/django_cotton/__init__.py
new file mode 100644
index 0000000..e262b60
--- /dev/null
+++ b/docs/docs_project/django_cotton/__init__.py
@@ -0,0 +1 @@
+_with_prop_prefix = "cotton_with_prop_"
diff --git a/docs/docs_project/django_cotton/apps.py b/docs/docs_project/django_cotton/apps.py
new file mode 100644
index 0000000..d90a50d
--- /dev/null
+++ b/docs/docs_project/django_cotton/apps.py
@@ -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)
diff --git a/docs/docs_project/django_cotton/cotton_loader.py b/docs/docs_project/django_cotton/cotton_loader.py
new file mode 100755
index 0000000..c4d13a7
--- /dev/null
+++ b/docs/docs_project/django_cotton/cotton_loader.py
@@ -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.
gets
+ compiled to
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 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 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 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)
diff --git a/docs/docs_project/django_cotton/templates/attribute_merging_test.cotton.html b/docs/docs_project/django_cotton/templates/attribute_merging_test.cotton.html
new file mode 100644
index 0000000..ebcf664
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/attribute_merging_test.cotton.html
@@ -0,0 +1,3 @@
+
+ ss
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/attribute_passing_test.cotton.html b/docs/docs_project/django_cotton/templates/attribute_passing_test.cotton.html
new file mode 100644
index 0000000..a7bc9fc
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/attribute_passing_test.cotton.html
@@ -0,0 +1,3 @@
+
+ ss
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/child_test.cotton.html b/docs/docs_project/django_cotton/templates/child_test.cotton.html
new file mode 100644
index 0000000..ec03be8
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/child_test.cotton.html
@@ -0,0 +1,3 @@
+
+ d
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/child.cotton.html b/docs/docs_project/django_cotton/templates/cotton/child.cotton.html
new file mode 100644
index 0000000..9f989fa
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/child.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/container.cotton.html b/docs/docs_project/django_cotton/templates/cotton/container.cotton.html
new file mode 100644
index 0000000..835e5c4
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/container.cotton.html
@@ -0,0 +1,11 @@
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/named_slot_component.cotton.html b/docs/docs_project/django_cotton/templates/cotton/named_slot_component.cotton.html
new file mode 100644
index 0000000..e324e2c
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/named_slot_component.cotton.html
@@ -0,0 +1,3 @@
+
+ {{ name }}
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/parent.cotton.html b/docs/docs_project/django_cotton/templates/cotton/parent.cotton.html
new file mode 100644
index 0000000..c534580
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/parent.cotton.html
@@ -0,0 +1,3 @@
+
+ {{slot}}
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/receives_attributes.cotton.html b/docs/docs_project/django_cotton/templates/cotton/receives_attributes.cotton.html
new file mode 100644
index 0000000..8d463b4
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/receives_attributes.cotton.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/cotton/test_component.cotton.html b/docs/docs_project/django_cotton/templates/cotton/test_component.cotton.html
new file mode 100644
index 0000000..c1cddfe
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/cotton/test_component.cotton.html
@@ -0,0 +1,13 @@
+
+
+
slot: '{{ slot }}'
+
+
attr1: '{{ attr1 }}'
+
attr2: '{{ attr2 }}'
+
+
prop1: '{{ prop1 }}'
+
default_prop: '{{ default_prop }}'
+
+
named_slot: '{{ named_slot }}'
+
+
attrs: '{{ attrs }}'
diff --git a/docs/docs_project/django_cotton/templates/django_syntax_decoding_test.cotton.html b/docs/docs_project/django_cotton/templates/django_syntax_decoding_test.cotton.html
new file mode 100644
index 0000000..ee3d784
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/django_syntax_decoding_test.cotton.html
@@ -0,0 +1 @@
+
Hello, World!
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/form_test.cotton.html b/docs/docs_project/django_cotton/templates/form_test.cotton.html
new file mode 100644
index 0000000..6c70273
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/form_test.cotton.html
@@ -0,0 +1,3 @@
+
+
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/named_slot_in_loop.cotton.html b/docs/docs_project/django_cotton/templates/named_slot_in_loop.cotton.html
new file mode 100644
index 0000000..136d7c1
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/named_slot_in_loop.cotton.html
@@ -0,0 +1,7 @@
+{% for item in items %}
+
+
+ item name: {{ item.name }}
+
+
+{% endfor %}
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/parent_test.cotton.html b/docs/docs_project/django_cotton/templates/parent_test.cotton.html
new file mode 100644
index 0000000..9922300
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/parent_test.cotton.html
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/self_closing_test.cotton.html b/docs/docs_project/django_cotton/templates/self_closing_test.cotton.html
new file mode 100644
index 0000000..9c99f37
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/self_closing_test.cotton.html
@@ -0,0 +1,4 @@
+{% load static %}
+
+
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/string_with_spaces.cotton.html b/docs/docs_project/django_cotton/templates/string_with_spaces.cotton.html
new file mode 100644
index 0000000..ab4ddb5
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/string_with_spaces.cotton.html
@@ -0,0 +1,5 @@
+
+
+ named_slot with spaces
+
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templates/variable_parsing_test.cotton.html b/docs/docs_project/django_cotton/templates/variable_parsing_test.cotton.html
new file mode 100644
index 0000000..8402bd4
--- /dev/null
+++ b/docs/docs_project/django_cotton/templates/variable_parsing_test.cotton.html
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/docs/docs_project/django_cotton/templatetags/__init__.py b/docs/docs_project/django_cotton/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/docs/docs_project/django_cotton/templatetags/_component.py b/docs/docs_project/django_cotton/templatetags/_component.py
new file mode 100644
index 0000000..eaf365c
--- /dev/null
+++ b/docs/docs_project/django_cotton/templatetags/_component.py
@@ -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
diff --git a/docs/docs_project/django_cotton/templatetags/_props.py b/docs/docs_project/django_cotton/templatetags/_props.py
new file mode 100644
index 0000000..e5f2961
--- /dev/null
+++ b/docs/docs_project/django_cotton/templatetags/_props.py
@@ -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 ""
diff --git a/docs/docs_project/django_cotton/templatetags/_props_frame.py b/docs/docs_project/django_cotton/templatetags/_props_frame.py
new file mode 100644
index 0000000..e1c5492
--- /dev/null
+++ b/docs/docs_project/django_cotton/templatetags/_props_frame.py
@@ -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 () 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)
diff --git a/docs/docs_project/django_cotton/templatetags/_slot.py b/docs/docs_project/django_cotton/templatetags/_slot.py
new file mode 100644
index 0000000..e25ba70
--- /dev/null
+++ b/docs/docs_project/django_cotton/templatetags/_slot.py
@@ -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 ""
diff --git a/docs/docs_project/django_cotton/templatetags/cotton.py b/docs/docs_project/django_cotton/templatetags/cotton.py
new file mode 100644
index 0000000..43c9b17
--- /dev/null
+++ b/docs/docs_project/django_cotton/templatetags/cotton.py
@@ -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())
diff --git a/docs/docs_project/django_cotton/tests/__init__.py b/docs/docs_project/django_cotton/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/docs/docs_project/django_cotton/tests/test_cotton.py b/docs/docs_project/django_cotton/tests/test_cotton.py
new file mode 100644
index 0000000..aa5bafe
--- /dev/null
+++ b/docs/docs_project/django_cotton/tests/test_cotton.py
@@ -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, '