mirror of
https://github.com/wrabit/django-cotton.git
synced 2025-08-03 14:48:17 +00:00
first release
This commit is contained in:
parent
c38c990b5b
commit
4c4d00d4df
2269 changed files with 323498 additions and 2 deletions
43
.github/workflows/deploy_docs.yml
vendored
Normal file
43
.github/workflows/deploy_docs.yml
vendored
Normal file
|
@ -0,0 +1,43 @@
|
|||
name: "Deploy Documentation"
|
||||
run-name: "Deploy Documentation (@${{ github.actor }})"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
RAILWAY_PROJECT_TOKEN:
|
||||
required: true
|
||||
RAILWAY_SERVICE:
|
||||
required: true
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'docs/**'
|
||||
|
||||
jobs:
|
||||
deploy_docs:
|
||||
env:
|
||||
RAILWAY_TOKEN: ${{ secrets.RAILWAY_PROJECT_TOKEN }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
|
||||
- name: Install Documentation Dependencies
|
||||
run: |
|
||||
cd docs/docs_project
|
||||
npm install
|
||||
|
||||
- name: Install Railway CLI
|
||||
run: npm install -g @railway/cli
|
||||
|
||||
- name: Deploy Documentation
|
||||
run: |
|
||||
cp -r django_cotton docs/docs_project
|
||||
railway up --service=${{ secrets.RAILWAY_SERVICE }}
|
48
.github/workflows/publish_to_pypi.yml
vendored
Normal file
48
.github/workflows/publish_to_pypi.yml
vendored
Normal file
|
@ -0,0 +1,48 @@
|
|||
name: "Publish to PyPI"
|
||||
run-name: "Publish to PyPI (@${{ github.actor }})"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '**'
|
||||
- '!docs/**'
|
||||
|
||||
jobs:
|
||||
publish_pypi:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: Install required libraries
|
||||
run: python3 -m pip install toml
|
||||
|
||||
- name: Update Version in pyproject.toml
|
||||
run: |
|
||||
git fetch origin main
|
||||
git reset --hard origin/main
|
||||
python -c "import toml; f = open('./pyproject.toml', 'r'); c = toml.load(f); f.close(); v = list(map(int, c['tool']['poetry']['version'].split('.'))); v[-1] += 1; c['tool']['poetry']['version'] = '.'.join(map(str, v)); f = open('./pyproject.toml', 'w'); toml.dump(c, f); f.close();"
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add -A
|
||||
git commit -m "Automatic version bump" --allow-empty
|
||||
git push origin main
|
||||
|
||||
- name: Build and publish to PyPI
|
||||
uses: JRubics/poetry-publish@v1.17
|
||||
with:
|
||||
pypi_token: ${{ secrets.PYPI_TOKEN }}
|
||||
|
||||
trigger_deploy_docs:
|
||||
needs: publish_pypi
|
||||
uses: ./.github/workflows/deploy_docs.yml
|
||||
secrets:
|
||||
RAILWAY_PROJECT_TOKEN: ${{ secrets.RAILWAY_PROJECT_TOKEN }}
|
||||
RAILWAY_SERVICE: ${{ secrets.RAILWAY_SERVICE }}
|
30
.github/workflows/test.yml
vendored
Normal file
30
.github/workflows/test.yml
vendored
Normal file
|
@ -0,0 +1,30 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
run_tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
docker build -f dev/docker/Dockerfile -t cotton-test-app dev/example_project
|
||||
|
||||
- name: Start Container
|
||||
run: |
|
||||
docker-compose -f dev/docker/docker-compose.yaml up -d
|
||||
|
||||
- name: Run Tests
|
||||
run: docker exec -t cotton-dev-app python manage.py test
|
||||
|
||||
- name: Stop and Remove Services
|
||||
run: docker-compose -f dev/docker/docker-compose.yaml down
|
115
.gitignore
vendored
Normal file
115
.gitignore
vendored
Normal file
|
@ -0,0 +1,115 @@
|
|||
# Django #
|
||||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
__pycache__
|
||||
db.sqlite3
|
||||
media
|
||||
**/static/app.css
|
||||
|
||||
# Backup files #
|
||||
*.bak
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
.idea/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Python #
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Distribution / packaging
|
||||
.Python build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
.pytest_cache/
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery
|
||||
celerybeat-schedule.*
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
|
||||
# Sublime Text #
|
||||
*.tmlanguage.cache
|
||||
*.tmPreferences.cache
|
||||
*.stTheme.cache
|
||||
*.sublime-workspace
|
||||
*.sublime-project
|
||||
|
||||
# sftp configuration file
|
||||
sftp-config.json
|
||||
|
||||
# Package control specific files Package
|
||||
Control.last-run
|
||||
Control.ca-list
|
||||
Control.ca-bundle
|
||||
Control.system-ca-bundle
|
||||
GitHub.sublime-settings
|
||||
|
||||
# Visual Studio Code #
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
.history
|
||||
|
||||
.DS_Store
|
||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2023 dyvenia
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
141
README.md
141
README.md
|
@ -1,2 +1,139 @@
|
|||
# cotton
|
||||
Bringing component based design to Django templates
|
||||
# Cotton
|
||||
|
||||
Bringing component-based design to Django templates.
|
||||
|
||||
<a href="https://www.django-cotton.com" target="_blank">Document site</a>
|
||||
|
||||
## Overview
|
||||
Cotton enhances Django templates by allowing component-based design, making UI composition more efficient and reusable. It integrates seamlessly with Tailwind CSS and retains full compatibility with native Django template features.
|
||||
|
||||
## Key Features
|
||||
- **Rapid UI Composition:** Efficiently compose and reuse UI components.
|
||||
- **Tailwind CSS Harmony:** Integrates with Tailwind's utility-first approach.
|
||||
- **Interoperable with Django:** Enhances Django templates without replacing them.
|
||||
- **Semantic Syntax:** HTML-like syntax for better code editor support.
|
||||
- **Minimal Overhead:** Compiles to native Django components with automatic caching.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
To install Cotton, run the following command:
|
||||
|
||||
```bash
|
||||
pip install django-cotton
|
||||
```
|
||||
|
||||
Then update your `settings.py`:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'django_cotton',
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': ['your_project/templates'],
|
||||
'APP_DIRS': False,
|
||||
'OPTIONS': {
|
||||
'loaders': [
|
||||
'django_cotton.template.loaders.CottonLoader',
|
||||
# continue with default loaders:
|
||||
# "django.template.loaders.filesystem.Loader",
|
||||
# "django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
'builtins': [
|
||||
'django_cotton.templatetags.cotton',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Quickstart
|
||||
Create a new directory in your templates directory called `cotton`. Inside this directory, create a new file called `card.cotton.html` with the following content:
|
||||
|
||||
```html
|
||||
<div class="bg-white shadow rounded border p-4">
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ slot }}</p>
|
||||
<button href="{% url url %}">Read more</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Create a view with a template. Views that contain Cotton components must also use the `.cotton.html` extension:
|
||||
|
||||
```python
|
||||
# views.py
|
||||
def dashboard_view(request):
|
||||
return render(request, "dashboard.cotton.html")
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- templates/dashboard.cotton.html -->
|
||||
<c-card title="Trees" url="trees">
|
||||
We have the best trees
|
||||
</c-card>
|
||||
|
||||
<c-card title="Spades" url="spades">
|
||||
The best spades in the land
|
||||
</c-card>
|
||||
```
|
||||
|
||||
### Usage Basics
|
||||
- **Template Extensions:** View templates including Cotton components should use the `.cotton.html` extension.
|
||||
- **Component Placement:** Components should be placed in the `templates/cotton` folder.
|
||||
- **Naming Conventions:**
|
||||
- Component filenames use snake_case: `my_component.cotton.html`
|
||||
- Components are called using kebab-case: `<c-my-component />`
|
||||
|
||||
### Example
|
||||
A minimal example using Cotton components:
|
||||
|
||||
```html
|
||||
<!-- my_component.cotton.html -->
|
||||
{{ slot }}
|
||||
|
||||
<!-- my_view.cotton.html -->
|
||||
<c-my-component>
|
||||
<p>Some content</p>
|
||||
</c-my-component>
|
||||
```
|
||||
|
||||
### Attributes and Slots
|
||||
Components can accept attributes and named slots for flexible content and behavior customization:
|
||||
|
||||
```html
|
||||
<!-- weather.cotton.html -->
|
||||
<p>It's {{ temperature }}<sup>{{ unit }}</sup> and the condition is {{ condition }}.</p>
|
||||
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather temperature="23" unit="c" condition="windy"></c-weather>
|
||||
```
|
||||
|
||||
#### Passing Variables
|
||||
To pass a variable from the parent's context, prepend the attribute with a `:`.
|
||||
|
||||
```html
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather :unit="unit"></c-weather>
|
||||
```
|
||||
|
||||
#### Named Slots
|
||||
```html
|
||||
<!-- weather_card.cotton.html -->
|
||||
<div class="flex ...">
|
||||
<h2>{{ day }}:</h2> {{ icon }} {{ label }}
|
||||
</div>
|
||||
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather-card day="Tuesday">
|
||||
<c-slot name="icon">
|
||||
<svg>...</svg>
|
||||
</c-slot>
|
||||
<c-slot name="label">
|
||||
<h2 class="text-yellow-500">Sunny</h2>
|
||||
</c-slot>
|
||||
</c-weather-card>
|
||||
```
|
29
dev/docker/Dockerfile
Normal file
29
dev/docker/Dockerfile
Normal file
|
@ -0,0 +1,29 @@
|
|||
ARG PLATFORM=linux/amd64
|
||||
|
||||
# Use an official Python runtime as a base image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Keep logs unbuffered
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install poetry
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only dependencies definition to the docker image
|
||||
COPY . /app/
|
||||
|
||||
# Install project dependencies
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install
|
||||
|
||||
CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ]
|
16
dev/docker/bin/build.sh
Executable file
16
dev/docker/bin/build.sh
Executable file
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
|
||||
# if arg exists 'mac', then build for mac
|
||||
if [ "$1" = "mac" ]; then
|
||||
PLATFORM="linux/arm64"
|
||||
else
|
||||
PLATFORM="linux/amd64"
|
||||
fi
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR
|
||||
|
||||
docker build --no-cache --build-arg PLATFORM=$PLATFORM -f ../Dockerfile ../../example_project -t cotton-test-app
|
4
dev/docker/bin/manage.sh
Executable file
4
dev/docker/bin/manage.sh
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Start a new login shell to preserve history and execute the Docker command
|
||||
bash -l -c "docker exec -it cotton-dev-app python manage.py $*"
|
9
dev/docker/bin/run-dev.sh
Executable file
9
dev/docker/bin/run-dev.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR
|
||||
|
||||
docker compose -f ../docker-compose.yaml up "$@"
|
9
dev/docker/bin/shell.sh
Executable file
9
dev/docker/bin/shell.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR/..
|
||||
|
||||
docker exec -it cotton-dev-app python manage.py shell
|
9
dev/docker/bin/stop.sh
Executable file
9
dev/docker/bin/stop.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR/..
|
||||
|
||||
docker compose -f etc/docker/docker-compose.yaml -f etc/docker/docker-compose.dev.yaml down $*
|
3
dev/docker/bin/terminal.sh
Executable file
3
dev/docker/bin/terminal.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker exec -it cotton-dev-app bash
|
3
dev/docker/bin/test.sh
Executable file
3
dev/docker/bin/test.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker exec -t cotton-dev-app python manage.py test $*
|
16
dev/docker/docker-compose.yaml
Normal file
16
dev/docker/docker-compose.yaml
Normal file
|
@ -0,0 +1,16 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
web:
|
||||
container_name: cotton-dev-app
|
||||
restart: always
|
||||
image: cotton-test-app
|
||||
working_dir: /app
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
environment:
|
||||
- DEBUG=True
|
||||
volumes:
|
||||
- ../example_project:/app
|
||||
- ../../django_cotton:/app/django_cotton
|
||||
ports:
|
||||
- 8001:8000
|
0
dev/example_project/example_project/__init__.py
Normal file
0
dev/example_project/example_project/__init__.py
Normal file
16
dev/example_project/example_project/asgi.py
Normal file
16
dev/example_project/example_project/asgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for django_cotton project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
|
||||
|
||||
application = get_asgi_application()
|
136
dev/example_project/example_project/settings.py
Normal file
136
dev/example_project/example_project/settings.py
Normal file
|
@ -0,0 +1,136 @@
|
|||
"""
|
||||
Django settings for django_cotton project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.2.8.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
# SETTINGS_PATH = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "django-insecure-%)7a&zw=le4uey^36*z*9^4#*iii65t)nyt$36mxq70@=(z6^n"
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = ["0.0.0.0"]
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_cotton",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "django_cotton.urls"
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": ["example_project/templates", "django_cotton/templates"],
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
"loaders": [
|
||||
"django_cotton.cotton_loader.Loader",
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
"builtins": [
|
||||
"django.templatetags.static",
|
||||
"django_cotton.templatetags.cotton",
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = "django_cotton.wsgi.application"
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": BASE_DIR / "db.sqlite3",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
TIME_ZONE = "UTC"
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "static/"
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||
|
||||
COTTON_TEMPLATE_CACHING_ENABLED = False
|
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-child>d</c-child>
|
||||
</c-parent>
|
|
@ -0,0 +1,9 @@
|
|||
<c-benchmarks.partials.main>
|
||||
I'm default
|
||||
<c-slot name="top">
|
||||
I'm top
|
||||
</c-slot>
|
||||
<c-slot name="bottom">
|
||||
I'm bottom
|
||||
</c-slot>
|
||||
</c-benchmarks.partials.main>
|
|
@ -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 %}
|
|
@ -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 %}
|
|
@ -0,0 +1,5 @@
|
|||
{{ top }}
|
||||
|
||||
{{ slot }}
|
||||
|
||||
{{ bottom }}
|
|
@ -0,0 +1,5 @@
|
|||
{% block top %}{% endblock %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
|
||||
{% block bottom %}{% endblock %}
|
|
@ -0,0 +1 @@
|
|||
<div class="i-am-child"></div>
|
|
@ -0,0 +1,3 @@
|
|||
<div>
|
||||
{{ name }}
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div class="i-am-parent">
|
||||
{{slot}}
|
||||
</div>
|
|
@ -0,0 +1,12 @@
|
|||
<c-props prop1="sds" prop_with_default="1" />
|
||||
|
||||
<div>
|
||||
{{ testy }}
|
||||
<p>prop1: '{{ prop1 }}'</p>
|
||||
<p>attr1: '{{ attr1 }}'</p>
|
||||
<p>empty_prop: '{{ empty_prop }}'</p>
|
||||
<p>prop_with_default: '{{ prop_with_default }}'</p>
|
||||
<p>slot: '{{ slot }}'</p>
|
||||
<p>named_slot: '{{ named_slot }}'</p>
|
||||
<p>attrs: '{{ attrs }}'</p>
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
|
||||
</c-parent>
|
|
@ -0,0 +1,7 @@
|
|||
{% for item in items %}
|
||||
<c-named-slot-component>
|
||||
<c-slot name="name">
|
||||
item name: {{ item.name }}
|
||||
</c-slot>
|
||||
</c-named-slot-component>
|
||||
{% endfor %}
|
|
@ -0,0 +1 @@
|
|||
<c-parent></c-parent>
|
|
@ -0,0 +1,3 @@
|
|||
<c-props-test-component prop1="im a prop" attr1="im an attr">
|
||||
default slot
|
||||
</c-props-test-component>
|
|
@ -0,0 +1,4 @@
|
|||
{% load static %}
|
||||
|
||||
|
||||
<c-parent/>
|
22
dev/example_project/manage.py
Executable file
22
dev/example_project/manage.py
Executable file
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
24
dev/example_project/pyproject.toml
Normal file
24
dev/example_project/pyproject.toml
Normal file
|
@ -0,0 +1,24 @@
|
|||
[project]
|
||||
name = "cotton-dev-app"
|
||||
requires-python = ">=3.8, <4"
|
||||
dependencies = [
|
||||
"django~=4.2.6",
|
||||
"beautifulsoup4~=4.12.2",
|
||||
"selenium~=4.13.0",
|
||||
"chromedriver-py~=117.0.5938.92",
|
||||
"webdriver-manager~=4.0.1"
|
||||
]
|
||||
|
||||
[tool.poetry]
|
||||
name = "cotton-dev-app"
|
||||
version = "0.1"
|
||||
description = "Development and test app for the django package."
|
||||
authors = ["Will Abbott <wabbott@dyvenia.com>"]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.8"
|
||||
Django = "^4.2"
|
||||
beautifulsoup4 = "~4.12.2"
|
||||
selenium = "~4.13.0"
|
||||
chromedriver-py = "~117.0.5938.92"
|
||||
webdriver-manager = "~4.0.1"
|
71
dev/example_project/render_load_test.py
Normal file
71
dev/example_project/render_load_test.py
Normal file
|
@ -0,0 +1,71 @@
|
|||
from django.conf import settings
|
||||
import time
|
||||
from django.template.loader import render_to_string
|
||||
import django
|
||||
|
||||
# Configure Django settings
|
||||
settings.configure(
|
||||
INSTALLED_APPS=[
|
||||
"django.contrib.admin",
|
||||
"django.contrib.auth",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_cotton",
|
||||
],
|
||||
TEMPLATES=[
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": ["example_project/templates"],
|
||||
"APP_DIRS": False,
|
||||
"OPTIONS": {
|
||||
"context_processors": [
|
||||
"django.template.context_processors.debug",
|
||||
"django.template.context_processors.request",
|
||||
"django.contrib.auth.context_processors.auth",
|
||||
"django.contrib.messages.context_processors.messages",
|
||||
],
|
||||
"loaders": [
|
||||
"django_cotton.cotton_loader.Loader",
|
||||
"django.template.loaders.filesystem.Loader",
|
||||
"django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
"builtins": [
|
||||
"django.templatetags.static",
|
||||
"django_cotton.templatetags.cotton",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
django.setup()
|
||||
|
||||
|
||||
def benchmark_template_rendering(template_name, iterations=1000):
|
||||
start_time = time.time()
|
||||
for _ in range(iterations):
|
||||
render_to_string(template_name)
|
||||
end_time = time.time()
|
||||
return end_time - start_time, render_to_string(template_name)
|
||||
|
||||
|
||||
# Benchmarking each template
|
||||
time_native_extends, output_native_extends = benchmark_template_rendering(
|
||||
"cotton/benchmarks/native_extends.html"
|
||||
)
|
||||
# time_native_include, output_native_include = benchmark_template_rendering('cotton/benchmarks/native_include.html')
|
||||
time_compiled_cotton, output_compiled_cotton = benchmark_template_rendering(
|
||||
"cotton/benchmarks/cotton_compiled.html"
|
||||
)
|
||||
time_cotton, output_cotton = benchmark_template_rendering(
|
||||
"cotton/benchmarks/cotton.cotton.html"
|
||||
)
|
||||
|
||||
|
||||
# Output results
|
||||
print(f"Native Django Template using extend: {time_native_extends} seconds")
|
||||
# print(f"Native Django Template using include: {time_native_include} seconds")
|
||||
print(f"Compiled Cotton Template: {time_compiled_cotton} seconds")
|
||||
print(f"Cotton Template: {time_cotton} seconds")
|
1
django_cotton/__init__.py
Normal file
1
django_cotton/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
_with_prop_prefix = "cotton_with_prop_"
|
291
django_cotton/cotton_loader.py
Executable file
291
django_cotton/cotton_loader.py
Executable file
|
@ -0,0 +1,291 @@
|
|||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import warnings
|
||||
|
||||
from django.template.loaders.base import Loader as BaseLoader
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.utils._os import safe_join
|
||||
from django.template import Template
|
||||
from django.core.cache import cache
|
||||
from django.template import Origin
|
||||
from django.conf import settings
|
||||
|
||||
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
|
||||
|
||||
|
||||
class Loader(BaseLoader):
|
||||
is_usable = True
|
||||
|
||||
def __init__(self, engine, dirs=None):
|
||||
super().__init__(engine)
|
||||
self.cache_handler = CottonTemplateCacheHandler()
|
||||
self.dirs = dirs
|
||||
self.django_syntax_placeholders = []
|
||||
|
||||
def get_template_from_string(self, template_string):
|
||||
"""Create and return a Template object from a string. Used primarily for testing."""
|
||||
return Template(template_string, engine=self.engine)
|
||||
|
||||
def get_contents(self, origin):
|
||||
# check if file exists, whilst getting the mtime for cache key
|
||||
try:
|
||||
mtime = os.path.getmtime(origin.name)
|
||||
except FileNotFoundError:
|
||||
raise TemplateDoesNotExist(origin)
|
||||
|
||||
# check and return cached template
|
||||
cache_key = self.cache_handler.get_cache_key(origin.template_name, mtime)
|
||||
cached_content = self.cache_handler.get_cached_template(cache_key)
|
||||
if cached_content is not None:
|
||||
return cached_content
|
||||
|
||||
# If not cached, process the template
|
||||
template_string = self._get_template_string(origin.name)
|
||||
|
||||
# We need to provide a key to the current view or component (in this case, view) so that we can namespace
|
||||
# slot data, preventing bleeding and ensure component's clear only data in the context applicable to itself
|
||||
# in this case, we're top level, likely in a view so we use the view template name as the key
|
||||
component_key = (
|
||||
origin.template_name.lstrip("cotton/")
|
||||
.rstrip(".cotton.html")
|
||||
.replace("/", ".")
|
||||
)
|
||||
|
||||
compiled_template = self._compile_template_from_string(
|
||||
template_string, component_key
|
||||
)
|
||||
|
||||
# Cache the processed template
|
||||
self.cache_handler.cache_template(cache_key, compiled_template)
|
||||
|
||||
return compiled_template
|
||||
|
||||
def _replace_syntax_with_placeholders(self, content):
|
||||
"""# replace {% ... %} and {{ ... }} with placeholders so they dont get touched
|
||||
or encoded by bs4. Store them to later switch them back in after transformation.
|
||||
"""
|
||||
self.django_syntax_placeholders = []
|
||||
|
||||
# First handle cotton_verbatim blocks, this is designed to preserve and display cotton syntax,
|
||||
# akin to the verbatim tag in Django.
|
||||
def replace_cotton_verbatim(match):
|
||||
inner_content = match.group(
|
||||
1
|
||||
) # Get the inner content without the cotton_verbatim tags
|
||||
self.django_syntax_placeholders.append(inner_content)
|
||||
return f"__django_syntax__{len(self.django_syntax_placeholders)}__"
|
||||
|
||||
# Replace cotton_verbatim blocks, capturing inner content
|
||||
content = re.sub(
|
||||
r"\{% cotton_verbatim %\}(.*?)\{% endcotton_verbatim %\}",
|
||||
replace_cotton_verbatim,
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
content = re.sub(
|
||||
r"\{%.*?%\}",
|
||||
lambda x: self.django_syntax_placeholders.append(x.group(0))
|
||||
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r"\{\{.*?\}\}",
|
||||
lambda x: self.django_syntax_placeholders.append(x.group(0))
|
||||
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
|
||||
content,
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _replace_placeholders_with_syntax(self, content):
|
||||
"""After modifying the content, replace the placeholders with the django template tags and variables."""
|
||||
for i, placeholder in enumerate(self.django_syntax_placeholders, 1):
|
||||
content = content.replace(f"__django_syntax__{i}__", placeholder)
|
||||
|
||||
return content
|
||||
|
||||
def _get_template_string(self, template_name):
|
||||
try:
|
||||
with open(template_name, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
return content
|
||||
|
||||
def _compile_template_from_string(self, content, component_key):
|
||||
content = self._replace_syntax_with_placeholders(content)
|
||||
content = self._compile_cotton_to_django(content, component_key)
|
||||
content = self._replace_placeholders_with_syntax(content)
|
||||
content = self._revert_bs4_attribute_empty_attribute_fixing(content)
|
||||
|
||||
return content
|
||||
|
||||
def _revert_bs4_attribute_empty_attribute_fixing(self, contents):
|
||||
"""Django's template parser adds ="" to empty attribute-like parts in any html-like node, i.e. <div {{ something }}> gets
|
||||
compiled to <div {{ something }}=""> Then if 'something' is holding attributes sets, the last attribute value is
|
||||
not quoted. i.e. model=test not model="test"."""
|
||||
cleaned_content = re.sub('}}=""', "}}", contents)
|
||||
return cleaned_content
|
||||
|
||||
def get_dirs(self):
|
||||
return self.dirs if self.dirs is not None else self.engine.dirs
|
||||
|
||||
def get_template_sources(self, template_name):
|
||||
"""Return an Origin object pointing to an absolute path in each directory
|
||||
in template_dirs. For security reasons, if a path doesn't lie inside
|
||||
one of the template_dirs it is excluded from the result set."""
|
||||
if template_name.endswith(".cotton.html"):
|
||||
for template_dir in self.get_dirs():
|
||||
try:
|
||||
name = safe_join(template_dir, template_name)
|
||||
except SuspiciousFileOperation:
|
||||
# The joined path was located outside of this template_dir
|
||||
# (it might be inside another one, so this isn't fatal).
|
||||
continue
|
||||
|
||||
yield Origin(
|
||||
name=name,
|
||||
template_name=template_name,
|
||||
loader=self,
|
||||
)
|
||||
|
||||
def _compile_cotton_to_django(self, html_content, component_key):
|
||||
"""Convert cotton <c-* syntax to {%."""
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
soup = self._wrap_with_cotton_props_frame(soup)
|
||||
self._transform_components(soup, component_key)
|
||||
|
||||
return str(soup)
|
||||
|
||||
def _transform_prop_tags(self, soup):
|
||||
c_props = soup.find_all("c-props")
|
||||
|
||||
for tag in c_props:
|
||||
# Build the cotton_props tag string
|
||||
props_list = []
|
||||
for prop, value in tag.attrs.items():
|
||||
if value is None:
|
||||
props_list.append(prop)
|
||||
else:
|
||||
props_list.append(f'{prop}="{value}"')
|
||||
|
||||
cotton_props_str = "{% cotton_props " + " ".join(props_list) + " %}"
|
||||
|
||||
# Replace the <c-props> tag with the cotton_props string.
|
||||
tag.replace_with(cotton_props_str)
|
||||
|
||||
return soup
|
||||
|
||||
def _wrap_with_cotton_props_frame(self, soup):
|
||||
"""Wrap content with {% cotton_props_frame %} to be able to govern props and attributes. In order to recognise
|
||||
props defined in a component and also have them available in context, we wrap the entire contents in another
|
||||
component: cotton_props_frame."""
|
||||
props_with_defaults = []
|
||||
c_props = soup.find("c-props")
|
||||
|
||||
# parse c-props tag to extract properties and defaults
|
||||
if c_props:
|
||||
props_with_defaults = []
|
||||
for prop, value in c_props.attrs.items():
|
||||
if value is None:
|
||||
props_with_defaults.append(f"{prop}={prop}")
|
||||
else:
|
||||
# Assuming value is already a string that represents the default value
|
||||
props_with_defaults.append(f'{prop}={prop}|default:"{value}"')
|
||||
|
||||
c_props.decompose()
|
||||
|
||||
# Construct the {% with %} opening tag
|
||||
opening = "{% cotton_props_frame " + " ".join(props_with_defaults) + " %}"
|
||||
closing = "{% endcotton_props_frame %}"
|
||||
|
||||
# Convert the remaining soup back to a string and wrap it within {% with %} block
|
||||
wrapped_content = opening + str(soup).strip() + closing
|
||||
|
||||
# Since we can't replace the soup object itself, we create new soup instead
|
||||
new_soup = BeautifulSoup(wrapped_content, "html.parser")
|
||||
|
||||
return new_soup
|
||||
|
||||
def _transform_named_slot(self, slot_tag, component_key):
|
||||
"""Replace <c-slot> tags with the {% cotton_slot %} template tag"""
|
||||
# for c_slot in soup.find_all("c-slot"):
|
||||
slot_name = slot_tag.get("name", "").strip()
|
||||
inner_html = "".join(str(content) for content in slot_tag.contents)
|
||||
|
||||
# Check and process any components in the slot content
|
||||
|
||||
slot_soup = BeautifulSoup(inner_html, "html.parser")
|
||||
self._transform_components(slot_soup, component_key)
|
||||
|
||||
cotton_slot_tag = f"{{% cotton_slot {slot_name} {component_key} %}}{str(slot_soup)}{{% end_cotton_slot %}}"
|
||||
|
||||
slot_tag.replace_with(BeautifulSoup(cotton_slot_tag, "html.parser"))
|
||||
|
||||
def _transform_components(self, soup, component_key):
|
||||
"""Replace <c-[component path]> tags with the {% cotton_component %} template tag"""
|
||||
for tag in soup.find_all(re.compile("^c-"), recursive=True):
|
||||
if tag.name == "c-slot":
|
||||
self._transform_named_slot(tag, component_key)
|
||||
|
||||
continue
|
||||
|
||||
component_name = tag.name[2:]
|
||||
|
||||
# Convert dot notation to path structure and replace hyphens with underscores
|
||||
path = component_name.replace(".", "/").replace("-", "_")
|
||||
|
||||
# Construct the opening tag
|
||||
opening_tag = f"{{% cotton_component {'cotton/{}.cotton.html'.format(path)} {component_name} "
|
||||
for attr, value in tag.attrs.items():
|
||||
if attr == "class":
|
||||
value = " ".join(value)
|
||||
opening_tag += ' {}="{}"'.format(attr, value)
|
||||
opening_tag += " %}"
|
||||
|
||||
# Construct the closing tag
|
||||
closing_tag = "{% end_cotton_component %}"
|
||||
|
||||
if tag.contents:
|
||||
tag_soup = BeautifulSoup(tag.decode_contents(), "html.parser")
|
||||
self._transform_components(tag_soup, component_name)
|
||||
|
||||
# Create new content with opening tag, tag content, and closing tag
|
||||
new_content = opening_tag + str(tag_soup) + closing_tag
|
||||
|
||||
else:
|
||||
# Create new content with opening tag and closing tag
|
||||
new_content = opening_tag + closing_tag
|
||||
|
||||
# Replace the original tag with the new content
|
||||
new_soup = BeautifulSoup(new_content, "html.parser")
|
||||
tag.replace_with(new_soup)
|
||||
|
||||
return soup
|
||||
|
||||
|
||||
class CottonTemplateCacheHandler:
|
||||
"""Handles caching of cotton templates so the html parsing is only done on first load of each view or component."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = getattr(settings, "TEMPLATE_CACHING_ENABLED", True)
|
||||
|
||||
def get_cache_key(self, template_name, mtime):
|
||||
template_hash = hashlib.sha256(template_name.encode()).hexdigest()
|
||||
return f"cotton_cache_{template_hash}_{mtime}"
|
||||
|
||||
def get_cached_template(self, cache_key):
|
||||
if not self.enabled:
|
||||
return None
|
||||
return cache.get(cache_key)
|
||||
|
||||
def cache_template(self, cache_key, content, timeout=None):
|
||||
if self.enabled:
|
||||
cache.set(cache_key, content, timeout=timeout)
|
|
@ -0,0 +1,3 @@
|
|||
<c-merges-attributes class="extra-class" silica:model="test" another="test">
|
||||
ss
|
||||
</c-merges-attributes>
|
|
@ -0,0 +1,3 @@
|
|||
<c-receives-attributes attribute_1="hello" and-another="woo1" thirdForLuck="yes">
|
||||
ss
|
||||
</c-receives-attributes>
|
3
django_cotton/templates/child_test.cotton.html
Normal file
3
django_cotton/templates/child_test.cotton.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-child>d</c-child>
|
||||
</c-parent>
|
1
django_cotton/templates/cotton/child.cotton.html
Normal file
1
django_cotton/templates/cotton/child.cotton.html
Normal file
|
@ -0,0 +1 @@
|
|||
<div class="i-am-child"></div>
|
11
django_cotton/templates/cotton/container.cotton.html
Normal file
11
django_cotton/templates/cotton/container.cotton.html
Normal file
|
@ -0,0 +1,11 @@
|
|||
<div>
|
||||
Header:
|
||||
{{ header }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Content:
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<div {{ attrs_dict|merge:'class:form-group another-class-with:colon' }}>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div>
|
||||
{{ name }}
|
||||
</div>
|
3
django_cotton/templates/cotton/parent.cotton.html
Normal file
3
django_cotton/templates/cotton/parent.cotton.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<div class="i-am-parent">
|
||||
{{slot}}
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div {{ attrs }}>
|
||||
|
||||
</div>
|
13
django_cotton/templates/cotton/test_component.cotton.html
Normal file
13
django_cotton/templates/cotton/test_component.cotton.html
Normal file
|
@ -0,0 +1,13 @@
|
|||
<c-props prop1 default_prop="default prop" />
|
||||
|
||||
<p>slot: '{{ slot }}'</p>
|
||||
|
||||
<p>attr1: '{{ attr1 }}'</p>
|
||||
<p>attr2: '{{ attr2 }}'</p>
|
||||
|
||||
<p>prop1: '{{ prop1 }}'</p>
|
||||
<p>default_prop: '{{ default_prop }}'</p>
|
||||
|
||||
<p>named_slot: '{{ named_slot }}'</p>
|
||||
|
||||
<p>attrs: '{{ attrs }}'</p>
|
|
@ -0,0 +1 @@
|
|||
<div class="{% if 1 < 2 %} some-class {% endif %}">Hello, World!</div>
|
3
django_cotton/templates/form_test.cotton.html
Normal file
3
django_cotton/templates/form_test.cotton.html
Normal file
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
|
||||
</c-parent>
|
7
django_cotton/templates/named_slot_in_loop.cotton.html
Normal file
7
django_cotton/templates/named_slot_in_loop.cotton.html
Normal file
|
@ -0,0 +1,7 @@
|
|||
{% for item in items %}
|
||||
<c-named-slot-component>
|
||||
<c-slot name="name">
|
||||
item name: {{ item.name }}
|
||||
</c-slot>
|
||||
</c-named-slot-component>
|
||||
{% endfor %}
|
1
django_cotton/templates/parent_test.cotton.html
Normal file
1
django_cotton/templates/parent_test.cotton.html
Normal file
|
@ -0,0 +1 @@
|
|||
<c-parent></c-parent>
|
4
django_cotton/templates/self_closing_test.cotton.html
Normal file
4
django_cotton/templates/self_closing_test.cotton.html
Normal file
|
@ -0,0 +1,4 @@
|
|||
{% load static %}
|
||||
|
||||
|
||||
<c-parent/>
|
5
django_cotton/templates/string_with_spaces.cotton.html
Normal file
5
django_cotton/templates/string_with_spaces.cotton.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
<c-test-component prop1="string with space" attr1="I have spaces">
|
||||
<c-slot name="named_slot">
|
||||
named_slot with spaces
|
||||
</c-slot>
|
||||
</c-test-component>
|
|
@ -0,0 +1,2 @@
|
|||
<c-test-component attr1="variable" :attr2="variable">
|
||||
</c-test-component>
|
0
django_cotton/templatetags/__init__.py
Normal file
0
django_cotton/templatetags/__init__.py
Normal file
68
django_cotton/templatetags/_component.py
Normal file
68
django_cotton/templatetags/_component.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from django import template
|
||||
from django.template import Node
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def cotton_component(parser, token):
|
||||
bits = token.split_contents()
|
||||
tag_name = bits[0]
|
||||
template_path = bits[1]
|
||||
component_key = bits[2]
|
||||
|
||||
kwargs = {}
|
||||
for bit in bits[3:]:
|
||||
key, value = bit.split("=")
|
||||
if key.startswith(":"): # Detect variables
|
||||
key = key[1:] # Remove ':' prefix
|
||||
value = value.strip("'\"") # Remove quotes
|
||||
kwargs[key] = template.Variable(value) # Treat as a variable
|
||||
else:
|
||||
kwargs[key] = value.strip("'\"") # Treat as a literal string
|
||||
|
||||
nodelist = parser.parse(("end_cotton_component",))
|
||||
parser.delete_first_token()
|
||||
|
||||
return CottonComponentNode(nodelist, template_path, component_key, kwargs)
|
||||
|
||||
|
||||
class CottonComponentNode(Node):
|
||||
def __init__(self, nodelist, template_path, component_key, kwargs):
|
||||
self.nodelist = nodelist
|
||||
self.template_path = template_path
|
||||
self.component_key = component_key
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render(self, context):
|
||||
local_context = context.flatten()
|
||||
|
||||
attrs = {}
|
||||
for key, value in self.kwargs.items():
|
||||
if isinstance(value, template.Variable): # Resolve variables
|
||||
try:
|
||||
resolved_value = value.resolve(context)
|
||||
attrs[key] = resolved_value
|
||||
except template.VariableDoesNotExist:
|
||||
pass # Handle variable not found, if necessary
|
||||
else:
|
||||
attrs[key] = value # Use literal string
|
||||
|
||||
# Add the remainder as the default slot
|
||||
rendered = self.nodelist.render(context)
|
||||
local_context.update({"slot": rendered})
|
||||
|
||||
slots = context.get("cotton_slots", {})
|
||||
component_slots = slots.get(self.component_key, {})
|
||||
|
||||
local_context.update(component_slots)
|
||||
local_context.update(attrs)
|
||||
local_context.update({"attrs_dict": attrs})
|
||||
|
||||
rendered = render_to_string(self.template_path, local_context)
|
||||
|
||||
# Now reset the component's slots in context to prevent bleeding
|
||||
if self.component_key in slots:
|
||||
slots[self.component_key] = {}
|
||||
|
||||
context.update({"cotton_slots": slots})
|
||||
|
||||
return rendered
|
43
django_cotton/templatetags/_props.py
Normal file
43
django_cotton/templatetags/_props.py
Normal file
|
@ -0,0 +1,43 @@
|
|||
from django import template
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def cotton_props(parser, token):
|
||||
# Split the token to get variable assignments
|
||||
parts = token.split_contents()
|
||||
cotton_props = {}
|
||||
for part in parts[1:]:
|
||||
key, value = part.split("=")
|
||||
cotton_props[key] = value
|
||||
|
||||
return CottonPropNode(cotton_props)
|
||||
|
||||
|
||||
class CottonPropNode(template.Node):
|
||||
def __init__(self, cotton_props):
|
||||
self.cotton_props = cotton_props
|
||||
|
||||
def render(self, context):
|
||||
resolved_props = {}
|
||||
|
||||
# if the same var is already set in context, it's being passed explicitly to override the cotton_var
|
||||
# if not, then we resolve it from the context
|
||||
for key, value in self.cotton_props.items():
|
||||
# if key in context:
|
||||
# resolved_props[key] = context[key]
|
||||
# continue
|
||||
try:
|
||||
resolved_props[key] = template.Variable(value).resolve(context)
|
||||
except (TypeError, template.VariableDoesNotExist):
|
||||
resolved_props[key] = value
|
||||
|
||||
cotton_props = {"cotton_props": resolved_props}
|
||||
|
||||
# Update the global context directly
|
||||
context.update(resolved_props)
|
||||
context.update(cotton_props)
|
||||
context.update({"cotton_props": resolved_props})
|
||||
context["cotton_props"].update(resolved_props)
|
||||
|
||||
return ""
|
64
django_cotton/templatetags/_props_frame.py
Normal file
64
django_cotton/templatetags/_props_frame.py
Normal file
|
@ -0,0 +1,64 @@
|
|||
from django import template
|
||||
from django.template.base import token_kwargs
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
def cotton_props_frame(parser, token):
|
||||
"""The job of the props frame is to filter component kwargs (attributes) against declared props. It has to be
|
||||
second component because we desire to declare props (<c-props />) inside the component template and therefore the
|
||||
component can not manipulate its own context from it's own template, instead we declare the props frame
|
||||
directly inside component"""
|
||||
bits = token.split_contents()[1:] # Skip the tag name
|
||||
# Parse token kwargs while maintaining token order
|
||||
tag_kwargs = token_kwargs(bits, parser)
|
||||
|
||||
nodelist = parser.parse(("endcotton_props_frame",))
|
||||
parser.delete_first_token()
|
||||
return CottonPropsFrameNode(nodelist, tag_kwargs)
|
||||
|
||||
|
||||
class CottonPropsFrameNode(template.Node):
|
||||
def __init__(self, nodelist, kwargs):
|
||||
self.nodelist = nodelist
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render(self, context):
|
||||
# Assume 'attrs' are passed from the parent and are available in the context
|
||||
parent_attrs = context.get("attrs_dict", {})
|
||||
|
||||
# Initialize props based on the frame's kwargs and parent attrs
|
||||
props = {}
|
||||
for key, value in self.kwargs.items():
|
||||
# Attempt to resolve each kwarg value (which may include template variables)
|
||||
resolved_value = value.resolve(context)
|
||||
# Check if the prop exists in parent attrs; if so, use it, otherwise use the resolved default
|
||||
if key in parent_attrs:
|
||||
props[key] = parent_attrs[key]
|
||||
else:
|
||||
props[key] = resolved_value
|
||||
|
||||
# Overwrite 'attrs' in the local context by excluding keys that are identified as props
|
||||
attrs_without_props = {k: v for k, v in parent_attrs.items() if k not in props}
|
||||
context["attrs_dict"] = attrs_without_props
|
||||
|
||||
# Provide all of the attrs as a string to pass to the component
|
||||
def ensure_quoted(value):
|
||||
if isinstance(value, str) and value.startswith('"') and value.endswith('"'):
|
||||
return value
|
||||
else:
|
||||
return f'"{value}"'
|
||||
|
||||
attrs = " ".join(
|
||||
[
|
||||
f"{key}={ensure_quoted(value)}"
|
||||
for key, value in attrs_without_props.items()
|
||||
]
|
||||
)
|
||||
|
||||
context.update({"attrs": mark_safe(attrs)})
|
||||
context.update(attrs_without_props)
|
||||
context.update(props)
|
||||
|
||||
return self.nodelist.render(context)
|
48
django_cotton/templatetags/_slot.py
Normal file
48
django_cotton/templatetags/_slot.py
Normal file
|
@ -0,0 +1,48 @@
|
|||
from django import template
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
|
||||
def cotton_slot(parser, token):
|
||||
try:
|
||||
tag_name, slot_name, component_key = token.split_contents()
|
||||
except ValueError:
|
||||
raise template.TemplateSyntaxError("incomplete c-slot %r" % token.contents)
|
||||
|
||||
nodelist = parser.parse(("end_cotton_slot",))
|
||||
parser.delete_first_token()
|
||||
return CottonSlotNode(slot_name, nodelist, component_key)
|
||||
|
||||
|
||||
class CottonSlotNode(template.Node):
|
||||
def __init__(self, slot_name, nodelist, component_key):
|
||||
self.slot_name = slot_name
|
||||
self.nodelist = nodelist
|
||||
self.component_key = component_key
|
||||
|
||||
def render(self, context):
|
||||
# Add the rendered content to the context.
|
||||
if "cotton_slots" not in context:
|
||||
context.update({"cotton_slots": {}})
|
||||
|
||||
# context["cotton_slots"][self.slot_name] = mark_safe(output)
|
||||
|
||||
output = self.nodelist.render(context)
|
||||
|
||||
# with context.push():
|
||||
# Temporarily store the slot's content in the new layer
|
||||
# if "cotton_slots" not in context:
|
||||
# context["cotton_slots"] = {}
|
||||
|
||||
if self.component_key not in context["cotton_slots"]:
|
||||
context["cotton_slots"][self.component_key] = {}
|
||||
|
||||
# if self.slot_name not in context["cotton_slots"][self.component_key]:
|
||||
# context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
|
||||
context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
|
||||
|
||||
# context.push()
|
||||
|
||||
# todo add scoping by component
|
||||
# context["cotton_slots"][self.component_key][self.slot_name] = mark_safe(output)
|
||||
|
||||
return ""
|
26
django_cotton/templatetags/cotton.py
Normal file
26
django_cotton/templatetags/cotton.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from django import template
|
||||
from django.utils.html import format_html_join
|
||||
|
||||
from django_cotton.templatetags._component import cotton_component
|
||||
from django_cotton.templatetags._slot import cotton_slot
|
||||
from django_cotton.templatetags._props import cotton_props
|
||||
from django_cotton.templatetags._props_frame import cotton_props_frame
|
||||
|
||||
register = template.Library()
|
||||
register.tag("cotton_component", cotton_component)
|
||||
register.tag("cotton_slot", cotton_slot)
|
||||
register.tag("cotton_props", cotton_props)
|
||||
register.tag("cotton_props_frame", cotton_props_frame)
|
||||
|
||||
|
||||
@register.filter
|
||||
def merge(attrs, args):
|
||||
# attrs is expected to be a dictionary of existing attributes
|
||||
# args is a string of additional attributes to merge, e.g., "class:extra-class"
|
||||
for arg in args.split(","):
|
||||
key, value = arg.split(":", 1)
|
||||
if key in attrs:
|
||||
attrs[key] = value + " " + attrs[key]
|
||||
else:
|
||||
attrs[key] = value
|
||||
return format_html_join(" ", '{0}="{1}"', attrs.items())
|
0
django_cotton/tests/__init__.py
Normal file
0
django_cotton/tests/__init__.py
Normal file
111
django_cotton/tests/test_cotton.py
Normal file
111
django_cotton/tests/test_cotton.py
Normal file
|
@ -0,0 +1,111 @@
|
|||
from django.test import TestCase
|
||||
|
||||
from django_cotton.tests.utils import get_compiled, get_rendered
|
||||
|
||||
|
||||
class CottonTestCase(TestCase):
|
||||
def test_parent_component_is_rendered(self):
|
||||
response = self.client.get("/parent")
|
||||
self.assertContains(response, '<div class="i-am-parent">')
|
||||
|
||||
def test_child_is_rendered(self):
|
||||
response = self.client.get("/child")
|
||||
self.assertContains(response, '<div class="i-am-parent">')
|
||||
self.assertContains(response, '<div class="i-am-child">')
|
||||
|
||||
def test_self_closing_is_rendered(self):
|
||||
response = self.client.get("/self-closing")
|
||||
self.assertContains(response, '<div class="i-am-parent">')
|
||||
|
||||
def test_named_slots_correctly_display_in_loop(self):
|
||||
response = self.client.get("/named-slot-in-loop")
|
||||
self.assertContains(response, "item name: Item 1")
|
||||
self.assertContains(response, "item name: Item 2")
|
||||
self.assertContains(response, "item name: Item 3")
|
||||
|
||||
def test_attribute_passing(self):
|
||||
response = self.client.get("/attribute-passing")
|
||||
self.assertContains(
|
||||
response, '<div and-another="woo1" attribute_1="hello" thirdforluck="yes">'
|
||||
)
|
||||
|
||||
def test_attribute_merging(self):
|
||||
response = self.client.get("/attribute-merging")
|
||||
self.assertContains(
|
||||
response, 'class="form-group another-class-with:colon extra-class"'
|
||||
)
|
||||
|
||||
def test_django_syntax_decoding(self):
|
||||
response = self.client.get("/django-syntax-decoding")
|
||||
self.assertContains(response, "some-class")
|
||||
|
||||
def test_props_are_converted_to_props_frame_tags(self):
|
||||
compiled = get_compiled(
|
||||
"""
|
||||
<c-props prop1="string with space" />
|
||||
|
||||
content
|
||||
"""
|
||||
)
|
||||
|
||||
self.assertEquals(
|
||||
compiled,
|
||||
"""{% cotton_props_frame prop1=prop1|default:"string with space" %}content{% endcotton_props_frame %}""",
|
||||
)
|
||||
|
||||
def test_attrs_do_not_contain_props(self):
|
||||
response = self.client.get("/props-test")
|
||||
self.assertContains(response, "attr1: 'im an attr'")
|
||||
self.assertContains(response, "prop1: 'im a prop'")
|
||||
self.assertContains(response, """attrs: 'attr1="im an attr"'""")
|
||||
|
||||
def test_strings_with_spaces_can_be_passed(self):
|
||||
response = self.client.get("/string-with-spaces")
|
||||
self.assertContains(response, "attr1: 'I have spaces'")
|
||||
self.assertContains(response, "prop1: 'string with space'")
|
||||
self.assertContains(response, "default_prop: 'default prop'")
|
||||
self.assertContains(response, "named_slot: '")
|
||||
self.assertContains(response, "named_slot with spaces")
|
||||
self.assertContains(response, """attrs: 'attr1="I have spaces"'""")
|
||||
|
||||
def test_named_slots_dont_bleed_into_sibling_components(self):
|
||||
html = """
|
||||
<c-test-component>
|
||||
component1
|
||||
<c-slot name="named_slot">named slot 1</c-slot>
|
||||
</c-test-component>
|
||||
<c-test-component>
|
||||
component2
|
||||
</c-test-component>
|
||||
"""
|
||||
|
||||
rendered = get_rendered(html)
|
||||
|
||||
self.assertTrue("named_slot: 'named slot 1'" in rendered)
|
||||
self.assertTrue("named_slot: ''" in rendered)
|
||||
|
||||
def test_template_variables_are_not_parsed(self):
|
||||
html = """
|
||||
<c-test-component attr1="variable" :attr2="variable">
|
||||
<c-slot name="named_slot">
|
||||
<a href="#" silica:click.prevent="variable = 'lineage'">test</a>
|
||||
</c-slot>
|
||||
</c-test-component>
|
||||
"""
|
||||
|
||||
rendered = get_rendered(html, {"variable": 1})
|
||||
|
||||
self.assertTrue("attr1: 'variable'" in rendered)
|
||||
self.assertTrue("attr2: '1'" in rendered)
|
||||
|
||||
def test_int_attributes(self):
|
||||
pass
|
||||
|
||||
def test_none_attributes(self):
|
||||
pass
|
||||
|
||||
def test_list_attributes(self):
|
||||
pass
|
||||
|
||||
def quotes_inside_quotes(self):
|
||||
pass
|
18
django_cotton/tests/utils.py
Normal file
18
django_cotton/tests/utils.py
Normal file
|
@ -0,0 +1,18 @@
|
|||
from django.template import Context, Template
|
||||
|
||||
from django_cotton.cotton_loader import Loader as CottonLoader
|
||||
|
||||
|
||||
def get_compiled(template_string):
|
||||
return CottonLoader(engine=None)._compile_template_from_string(
|
||||
template_string, component_key="test_key"
|
||||
)
|
||||
|
||||
|
||||
def get_rendered(template_string, context: dict = None):
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
compiled_string = get_compiled(template_string)
|
||||
|
||||
return Template(compiled_string).render(Context(context))
|
45
django_cotton/urls.py
Normal file
45
django_cotton/urls.py
Normal file
|
@ -0,0 +1,45 @@
|
|||
from django.views.generic import TemplateView
|
||||
from . import views
|
||||
from django.urls import path
|
||||
|
||||
app_name = "django_cotton"
|
||||
|
||||
|
||||
class NamedSlotInLoop(TemplateView):
|
||||
template_name = "named_slot_in_loop.cotton.html"
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
return {
|
||||
"items": [
|
||||
{"name": "Item 1"},
|
||||
{"name": "Item 2"},
|
||||
{"name": "Item 3"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("parent", TemplateView.as_view(template_name="parent_test.cotton.html")),
|
||||
path("child", TemplateView.as_view(template_name="child_test.cotton.html")),
|
||||
path(
|
||||
"self-closing",
|
||||
TemplateView.as_view(template_name="self_closing_test.cotton.html"),
|
||||
),
|
||||
path("include", TemplateView.as_view(template_name="cotton_include.cotton.html")),
|
||||
path("playground", TemplateView.as_view(template_name="playground.cotton.html")),
|
||||
path("tag", TemplateView.as_view(template_name="tag.cotton.html")),
|
||||
path("named-slot-in-loop", NamedSlotInLoop.as_view()),
|
||||
path("test/compiled-cotton", views.compiled_cotton_test_view),
|
||||
path("test/cotton", views.cotton_test_view),
|
||||
path("test/native-extends", views.native_extends_test_view),
|
||||
path("test/native-include", views.native_include_test_view),
|
||||
path("attribute-merging", views.attribute_merging_test_view),
|
||||
path("attribute-passing", views.attribute_passing_test_view),
|
||||
path("django-syntax-decoding", views.django_syntax_decoding_test_view),
|
||||
path(
|
||||
"string-with-spaces",
|
||||
TemplateView.as_view(template_name="string_with_spaces.cotton.html"),
|
||||
),
|
||||
path("props-test", TemplateView.as_view(template_name="props_test.cotton.html")),
|
||||
path("variable-parsing", views.variable_parsing_test_view),
|
||||
]
|
40
django_cotton/views.py
Normal file
40
django_cotton/views.py
Normal file
|
@ -0,0 +1,40 @@
|
|||
from django.shortcuts import render
|
||||
|
||||
# benchmark tests
|
||||
|
||||
|
||||
def compiled_cotton_test_view(request):
|
||||
return render(request, "compiled_cotton_test.html")
|
||||
|
||||
|
||||
def cotton_test_view(request):
|
||||
return render(request, "cotton_test.cotton.html")
|
||||
|
||||
|
||||
def native_extends_test_view(request):
|
||||
return render(request, "native_extends_test.html")
|
||||
|
||||
|
||||
def native_include_test_view(request):
|
||||
return render(request, "native_include_test.html")
|
||||
|
||||
|
||||
# Django tests
|
||||
|
||||
|
||||
def attribute_merging_test_view(request):
|
||||
return render(request, "attribute_merging_test.cotton.html")
|
||||
|
||||
|
||||
def attribute_passing_test_view(request):
|
||||
return render(request, "attribute_passing_test.cotton.html")
|
||||
|
||||
|
||||
def django_syntax_decoding_test_view(request):
|
||||
return render(request, "django_syntax_decoding_test.cotton.html")
|
||||
|
||||
|
||||
def variable_parsing_test_view(request):
|
||||
return render(
|
||||
request, "variable_parsing_test.cotton.html", {"variable": "some-class"}
|
||||
)
|
16
django_cotton/wsgi.py
Normal file
16
django_cotton/wsgi.py
Normal file
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for django_cotton project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "example_project.settings")
|
||||
|
||||
application = get_wsgi_application()
|
39
docs/docker/Dockerfile
Normal file
39
docs/docker/Dockerfile
Normal file
|
@ -0,0 +1,39 @@
|
|||
ARG PLATFORM=linux/amd64
|
||||
|
||||
# ---- Tailwind Build Stage (build is quicker like this vs installing npm, node separately) ----
|
||||
FROM node:16-slim AS build_tailwind
|
||||
WORKDIR /css
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN ["npx", "tailwindcss", "-o", "./docs_project/static/app.css"]
|
||||
|
||||
# Use an official Python runtime as a base image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Keep logs unbuffered
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install poetry
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only dependencies definition to the docker image
|
||||
COPY . /app/
|
||||
|
||||
# Install project dependencies
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install
|
||||
|
||||
RUN SECRET_KEY=dummy STATIC_URL='/staticfiles/' python manage.py collectstatic --noinput --verbosity 2
|
||||
|
||||
CMD [ "python", "manage.py", "runserver", "0.0.0.0:8000" ]
|
16
docs/docker/bin/build.sh
Executable file
16
docs/docker/bin/build.sh
Executable file
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
|
||||
# if arg exists 'mac', then build for mac
|
||||
if [ "$1" = "mac" ]; then
|
||||
PLATFORM="linux/arm64"
|
||||
else
|
||||
PLATFORM="linux/amd64"
|
||||
fi
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR
|
||||
|
||||
docker build --no-cache --build-arg PLATFORM=$PLATFORM -f ../Dockerfile ../../docs_project -t cotton-docs-app
|
4
docs/docker/bin/manage.sh
Executable file
4
docs/docker/bin/manage.sh
Executable file
|
@ -0,0 +1,4 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Start a new login shell to preserve history and execute the Docker command
|
||||
bash -l -c "docker exec -it cotton-docs-web python manage.py $*"
|
9
docs/docker/bin/run-dev.sh
Executable file
9
docs/docker/bin/run-dev.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR
|
||||
|
||||
docker compose -f ../docker-compose.yaml up "$@"
|
9
docs/docker/bin/shell.sh
Executable file
9
docs/docker/bin/shell.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR/..
|
||||
|
||||
docker exec -it cotton-docs-web python manage.py shell
|
9
docs/docker/bin/stop.sh
Executable file
9
docs/docker/bin/stop.sh
Executable file
|
@ -0,0 +1,9 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Determine script directory
|
||||
SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )
|
||||
|
||||
# Change directory to project directory
|
||||
cd $SCRIPT_DIR/..
|
||||
|
||||
docker compose -f etc/docker/docker-compose.yaml -f etc/docker/docker-compose.dev.yaml down $*
|
3
docs/docker/bin/terminal.sh
Executable file
3
docs/docker/bin/terminal.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker exec -it cotton-docs-web bash
|
3
docs/docker/bin/test.sh
Executable file
3
docs/docker/bin/test.sh
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
docker exec -t cotton-docs-web python manage.py test $*
|
28
docs/docker/docker-compose.yaml
Normal file
28
docs/docker/docker-compose.yaml
Normal file
|
@ -0,0 +1,28 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
cotton-docs-web:
|
||||
container_name: cotton-docs-web
|
||||
restart: always
|
||||
image: cotton-docs-app
|
||||
working_dir: /app
|
||||
command: python manage.py runserver 0.0.0.0:8000
|
||||
environment:
|
||||
- DEBUG=True
|
||||
volumes:
|
||||
- ../docs_project:/app
|
||||
- ../../django_cotton:/app/django_cotton
|
||||
ports:
|
||||
- 8002:8000
|
||||
|
||||
cotton-docs-tailwind:
|
||||
container_name: cotton-docs-tailwind
|
||||
stop_signal: SIGINT
|
||||
image: node:16
|
||||
working_dir: /app
|
||||
tty: true
|
||||
volumes:
|
||||
- ../docs_project:/app
|
||||
# call the tailwind build before the watch
|
||||
# command: /bin/sh -c "npm install && npx tailwindcss -c ./tailwind.config.js -o ./luma/static/app.css && npx tailwindcss -c ./tailwind.config.js -o ./luma/static/app.css --watch"
|
||||
command: /bin/sh -c "trap 'exit' INT; npm install && npx tailwindcss -c ./tailwind.config.js -o ./docs_project/static/app.css && npx tailwindcss -c ./tailwind.config.js -o ./docs_project/static/app.css --watch"
|
45
docs/docs_project/DockerfileTmp
Normal file
45
docs/docs_project/DockerfileTmp
Normal file
|
@ -0,0 +1,45 @@
|
|||
ARG PLATFORM=linux/amd64
|
||||
|
||||
# ---- Tailwind Build Stage (build is quicker like this vs installing npm, node separately) ----
|
||||
FROM node:16-slim AS build_tailwind
|
||||
WORKDIR /css
|
||||
COPY package*.json ./
|
||||
RUN npm install
|
||||
COPY . .
|
||||
RUN ["npx", "tailwindcss", "-o", "./docs_project/static/app.css"]
|
||||
|
||||
# Use an official Python runtime as a base image
|
||||
FROM python:3.9-slim
|
||||
|
||||
# Keep logs unbuffered
|
||||
ENV PYTHONUNBUFFERED 1
|
||||
|
||||
# Set the working directory in the container
|
||||
WORKDIR /app
|
||||
|
||||
# Install Poetry
|
||||
RUN pip install --upgrade pip \
|
||||
&& pip install poetry
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
chromium \
|
||||
chromium-driver \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only dependencies definition to the docker image
|
||||
COPY . /app/
|
||||
|
||||
# Copy static files from the build_tailwind stage
|
||||
COPY --from=build_tailwind /css/docs_project/static/app.css ./docs_project/static/app.css
|
||||
|
||||
# Install project dependencies
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install
|
||||
|
||||
RUN SECRET_KEY=dummy STATIC_URL='/staticfiles/' python manage.py collectstatic --noinput --verbosity 2
|
||||
|
||||
EXPOSE 8000
|
||||
ENV PORT 8000
|
||||
|
||||
CMD ["gunicorn", "docs_project.wsgi:application", "--bind", "0.0.0.0:8000"]
|
139
docs/docs_project/README.md
Normal file
139
docs/docs_project/README.md
Normal file
|
@ -0,0 +1,139 @@
|
|||
# Cotton
|
||||
|
||||
Bringing component-based design to Django templates.
|
||||
|
||||
<a href="https://www.django-cotton.com" target="_blank">Document site</a>
|
||||
|
||||
## Overview
|
||||
Cotton enhances Django templates by allowing component-based design, making UI composition more efficient and reusable. It integrates seamlessly with Tailwind CSS and retains full compatibility with native Django template features.
|
||||
|
||||
## Key Features
|
||||
- **Rapid UI Composition:** Efficiently compose and reuse UI components.
|
||||
- **Tailwind CSS Harmony:** Integrates with Tailwind's utility-first approach.
|
||||
- **Interoperable with Django:** Enhances Django templates without replacing them.
|
||||
- **Semantic Syntax:** HTML-like syntax for better code editor support.
|
||||
- **Minimal Overhead:** Compiles to native Django components with automatic caching.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
To install Cotton, run the following command:
|
||||
|
||||
```bash
|
||||
pip install django-cotton
|
||||
```
|
||||
|
||||
Then update your `settings.py`:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'django_cotton',
|
||||
]
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': ['your_project/templates'],
|
||||
'APP_DIRS': False,
|
||||
'OPTIONS': {
|
||||
'loaders': [
|
||||
'django_cotton.template.loaders.CottonLoader',
|
||||
# continue with default loaders:
|
||||
# "django.template.loaders.filesystem.Loader",
|
||||
# "django.template.loaders.app_directories.Loader",
|
||||
],
|
||||
'builtins': [
|
||||
'django_cotton.templatetags.cotton',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
### Quickstart
|
||||
Create a new directory in your templates directory called `cotton`. Inside this directory, create a new file called `card.cotton.html` with the following content:
|
||||
|
||||
```html
|
||||
<div class="bg-white shadow rounded border p-4">
|
||||
<h2>{{ title }}</h2>
|
||||
<p>{{ slot }}</p>
|
||||
<button href="{% url url %}">Read more</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
Create a view with a template. Views that contain Cotton components must also use the `.cotton.html` extension:
|
||||
|
||||
```python
|
||||
# views.py
|
||||
def dashboard_view(request):
|
||||
return render(request, "dashboard.cotton.html")
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- templates/dashboard.cotton.html -->
|
||||
<c-card title="Trees" url="trees">
|
||||
We have the best trees
|
||||
</c-card>
|
||||
|
||||
<c-card title="Spades" url="spades">
|
||||
The best spades in the land
|
||||
</c-card>
|
||||
```
|
||||
|
||||
### Usage Basics
|
||||
- **Template Extensions:** View templates including Cotton components should use the `.cotton.html` extension.
|
||||
- **Component Placement:** Components should be placed in the `templates/cotton` folder.
|
||||
- **Naming Conventions:**
|
||||
- Component filenames use snake_case: `my_component.cotton.html`
|
||||
- Components are called using kebab-case: `<c-my-component />`
|
||||
|
||||
### Example
|
||||
A minimal example using Cotton components:
|
||||
|
||||
```html
|
||||
<!-- my_component.cotton.html -->
|
||||
{{ slot }}
|
||||
|
||||
<!-- my_view.cotton.html -->
|
||||
<c-my-component>
|
||||
<p>Some content</p>
|
||||
</c-my-component>
|
||||
```
|
||||
|
||||
### Attributes and Slots
|
||||
Components can accept attributes and named slots for flexible content and behavior customization:
|
||||
|
||||
```html
|
||||
<!-- weather.cotton.html -->
|
||||
<p>It's {{ temperature }}<sup>{{ unit }}</sup> and the condition is {{ condition }}.</p>
|
||||
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather temperature="23" unit="c" condition="windy"></c-weather>
|
||||
```
|
||||
|
||||
#### Passing Variables
|
||||
To pass a variable from the parent's context, prepend the attribute with a `:`.
|
||||
|
||||
```html
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather :unit="unit"></c-weather>
|
||||
```
|
||||
|
||||
#### Named Slots
|
||||
```html
|
||||
<!-- weather_card.cotton.html -->
|
||||
<div class="flex ...">
|
||||
<h2>{{ day }}:</h2> {{ icon }} {{ label }}
|
||||
</div>
|
||||
|
||||
<!-- view.cotton.html -->
|
||||
<c-weather-card day="Tuesday">
|
||||
<c-slot name="icon">
|
||||
<svg>...</svg>
|
||||
</c-slot>
|
||||
<c-slot name="label">
|
||||
<h2 class="text-yellow-500">Sunny</h2>
|
||||
</c-slot>
|
||||
</c-weather-card>
|
||||
```
|
1
docs/docs_project/django_cotton/__init__.py
Normal file
1
docs/docs_project/django_cotton/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
_with_prop_prefix = "cotton_with_prop_"
|
60
docs/docs_project/django_cotton/apps.py
Normal file
60
docs/docs_project/django_cotton/apps.py
Normal file
|
@ -0,0 +1,60 @@
|
|||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.template import engines
|
||||
|
||||
# fmt: off
|
||||
class CottonConfig(AppConfig):
|
||||
name = "django_cotton"
|
||||
verbose_name = "Cotton"
|
||||
|
||||
def ready(self):
|
||||
self._modify_templates_settings()
|
||||
self._add_builtin_template_tag()
|
||||
|
||||
def _modify_templates_settings(self):
|
||||
modified = False
|
||||
for template in settings.TEMPLATES:
|
||||
if not template.get("APP_DIRS", True):
|
||||
# If APP_DIRS is explicitly set to False, we assume the user
|
||||
# has a custom setup and do not modify the settings and provide tutorial instead.
|
||||
continue
|
||||
|
||||
# Example modification: Set APP_DIRS to False
|
||||
template["APP_DIRS"] = False
|
||||
|
||||
# Add your custom template loader
|
||||
if "OPTIONS" not in template:
|
||||
template["OPTIONS"] = {}
|
||||
if "loaders" not in template["OPTIONS"]:
|
||||
template["OPTIONS"]["loaders"] = []
|
||||
|
||||
# Add django_cotton loader
|
||||
if "django_cotton.cotton_loader.Loader" not in template["OPTIONS"]["loaders"]:
|
||||
template["OPTIONS"]["loaders"].insert(
|
||||
0, "django_cotton.cotton_loader.Loader"
|
||||
)
|
||||
|
||||
# Ensure default loaders are present, then add your custom loader
|
||||
if "django.template.loaders.filesystem.Loader" not in template["OPTIONS"]["loaders"]:
|
||||
template["OPTIONS"]["loaders"].append(
|
||||
"django.template.loaders.filesystem.Loader"
|
||||
)
|
||||
if "django.template.loaders.app_directories.Loader" not in template["OPTIONS"]["loaders"]:
|
||||
template["OPTIONS"]["loaders"].append(
|
||||
"django.template.loaders.app_directories.Loader"
|
||||
)
|
||||
|
||||
# Specify TEMPLATE_DIRS if necessary
|
||||
# template['DIRS'] += ['path/to/your/templates']
|
||||
|
||||
modified = True
|
||||
|
||||
if modified:
|
||||
print("TEMPLATES setting modified by Django Cotton.")
|
||||
|
||||
def _add_builtin_template_tag(self):
|
||||
"""Add a custom template tag to the built-ins."""
|
||||
builtins = engines["django"].engine.builtins
|
||||
custom_tag_lib = "django_cotton.templatetags.cotton"
|
||||
if custom_tag_lib not in builtins:
|
||||
builtins.append(custom_tag_lib)
|
291
docs/docs_project/django_cotton/cotton_loader.py
Executable file
291
docs/docs_project/django_cotton/cotton_loader.py
Executable file
|
@ -0,0 +1,291 @@
|
|||
import os
|
||||
import re
|
||||
import hashlib
|
||||
import warnings
|
||||
|
||||
from django.template.loaders.base import Loader as BaseLoader
|
||||
from django.core.exceptions import SuspiciousFileOperation
|
||||
from django.template import TemplateDoesNotExist
|
||||
from django.utils._os import safe_join
|
||||
from django.template import Template
|
||||
from django.core.cache import cache
|
||||
from django.template import Origin
|
||||
from django.conf import settings
|
||||
|
||||
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
|
||||
|
||||
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
|
||||
|
||||
|
||||
class Loader(BaseLoader):
|
||||
is_usable = True
|
||||
|
||||
def __init__(self, engine, dirs=None):
|
||||
super().__init__(engine)
|
||||
self.cache_handler = CottonTemplateCacheHandler()
|
||||
self.dirs = dirs
|
||||
self.django_syntax_placeholders = []
|
||||
|
||||
def get_template_from_string(self, template_string):
|
||||
"""Create and return a Template object from a string. Used primarily for testing."""
|
||||
return Template(template_string, engine=self.engine)
|
||||
|
||||
def get_contents(self, origin):
|
||||
# check if file exists, whilst getting the mtime for cache key
|
||||
try:
|
||||
mtime = os.path.getmtime(origin.name)
|
||||
except FileNotFoundError:
|
||||
raise TemplateDoesNotExist(origin)
|
||||
|
||||
# check and return cached template
|
||||
cache_key = self.cache_handler.get_cache_key(origin.template_name, mtime)
|
||||
cached_content = self.cache_handler.get_cached_template(cache_key)
|
||||
if cached_content is not None:
|
||||
return cached_content
|
||||
|
||||
# If not cached, process the template
|
||||
template_string = self._get_template_string(origin.name)
|
||||
|
||||
# We need to provide a key to the current view or component (in this case, view) so that we can namespace
|
||||
# slot data, preventing bleeding and ensure component's clear only data in the context applicable to itself
|
||||
# in this case, we're top level, likely in a view so we use the view template name as the key
|
||||
component_key = (
|
||||
origin.template_name.lstrip("cotton/")
|
||||
.rstrip(".cotton.html")
|
||||
.replace("/", ".")
|
||||
)
|
||||
|
||||
compiled_template = self._compile_template_from_string(
|
||||
template_string, component_key
|
||||
)
|
||||
|
||||
# Cache the processed template
|
||||
self.cache_handler.cache_template(cache_key, compiled_template)
|
||||
|
||||
return compiled_template
|
||||
|
||||
def _replace_syntax_with_placeholders(self, content):
|
||||
"""# replace {% ... %} and {{ ... }} with placeholders so they dont get touched
|
||||
or encoded by bs4. Store them to later switch them back in after transformation.
|
||||
"""
|
||||
self.django_syntax_placeholders = []
|
||||
|
||||
# First handle cotton_verbatim blocks, this is designed to preserve and display cotton syntax,
|
||||
# akin to the verbatim tag in Django.
|
||||
def replace_cotton_verbatim(match):
|
||||
inner_content = match.group(
|
||||
1
|
||||
) # Get the inner content without the cotton_verbatim tags
|
||||
self.django_syntax_placeholders.append(inner_content)
|
||||
return f"__django_syntax__{len(self.django_syntax_placeholders)}__"
|
||||
|
||||
# Replace cotton_verbatim blocks, capturing inner content
|
||||
content = re.sub(
|
||||
r"\{% cotton_verbatim %\}(.*?)\{% endcotton_verbatim %\}",
|
||||
replace_cotton_verbatim,
|
||||
content,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
|
||||
content = re.sub(
|
||||
r"\{%.*?%\}",
|
||||
lambda x: self.django_syntax_placeholders.append(x.group(0))
|
||||
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
|
||||
content,
|
||||
)
|
||||
content = re.sub(
|
||||
r"\{\{.*?\}\}",
|
||||
lambda x: self.django_syntax_placeholders.append(x.group(0))
|
||||
or f"__django_syntax__{len(self.django_syntax_placeholders)}__",
|
||||
content,
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
def _replace_placeholders_with_syntax(self, content):
|
||||
"""After modifying the content, replace the placeholders with the django template tags and variables."""
|
||||
for i, placeholder in enumerate(self.django_syntax_placeholders, 1):
|
||||
content = content.replace(f"__django_syntax__{i}__", placeholder)
|
||||
|
||||
return content
|
||||
|
||||
def _get_template_string(self, template_name):
|
||||
try:
|
||||
with open(template_name, "r") as f:
|
||||
content = f.read()
|
||||
except FileNotFoundError:
|
||||
raise TemplateDoesNotExist(template_name)
|
||||
|
||||
return content
|
||||
|
||||
def _compile_template_from_string(self, content, component_key):
|
||||
content = self._replace_syntax_with_placeholders(content)
|
||||
content = self._compile_cotton_to_django(content, component_key)
|
||||
content = self._replace_placeholders_with_syntax(content)
|
||||
content = self._revert_bs4_attribute_empty_attribute_fixing(content)
|
||||
|
||||
return content
|
||||
|
||||
def _revert_bs4_attribute_empty_attribute_fixing(self, contents):
|
||||
"""Django's template parser adds ="" to empty attribute-like parts in any html-like node, i.e. <div {{ something }}> gets
|
||||
compiled to <div {{ something }}=""> Then if 'something' is holding attributes sets, the last attribute value is
|
||||
not quoted. i.e. model=test not model="test"."""
|
||||
cleaned_content = re.sub('}}=""', "}}", contents)
|
||||
return cleaned_content
|
||||
|
||||
def get_dirs(self):
|
||||
return self.dirs if self.dirs is not None else self.engine.dirs
|
||||
|
||||
def get_template_sources(self, template_name):
|
||||
"""Return an Origin object pointing to an absolute path in each directory
|
||||
in template_dirs. For security reasons, if a path doesn't lie inside
|
||||
one of the template_dirs it is excluded from the result set."""
|
||||
if template_name.endswith(".cotton.html"):
|
||||
for template_dir in self.get_dirs():
|
||||
try:
|
||||
name = safe_join(template_dir, template_name)
|
||||
except SuspiciousFileOperation:
|
||||
# The joined path was located outside of this template_dir
|
||||
# (it might be inside another one, so this isn't fatal).
|
||||
continue
|
||||
|
||||
yield Origin(
|
||||
name=name,
|
||||
template_name=template_name,
|
||||
loader=self,
|
||||
)
|
||||
|
||||
def _compile_cotton_to_django(self, html_content, component_key):
|
||||
"""Convert cotton <c-* syntax to {%."""
|
||||
soup = BeautifulSoup(html_content, "html.parser")
|
||||
|
||||
soup = self._wrap_with_cotton_props_frame(soup)
|
||||
self._transform_components(soup, component_key)
|
||||
|
||||
return str(soup)
|
||||
|
||||
def _transform_prop_tags(self, soup):
|
||||
c_props = soup.find_all("c-props")
|
||||
|
||||
for tag in c_props:
|
||||
# Build the cotton_props tag string
|
||||
props_list = []
|
||||
for prop, value in tag.attrs.items():
|
||||
if value is None:
|
||||
props_list.append(prop)
|
||||
else:
|
||||
props_list.append(f'{prop}="{value}"')
|
||||
|
||||
cotton_props_str = "{% cotton_props " + " ".join(props_list) + " %}"
|
||||
|
||||
# Replace the <c-props> tag with the cotton_props string
|
||||
tag.replace_with(cotton_props_str)
|
||||
|
||||
return soup
|
||||
|
||||
def _wrap_with_cotton_props_frame(self, soup):
|
||||
"""Wrap content with {% cotton_props_frame %} to be able to govern props and attributes. In order to recognise
|
||||
props defined in a component and also have them available in context, we wrap the entire contents in another
|
||||
component: cotton_props_frame."""
|
||||
props_with_defaults = []
|
||||
c_props = soup.find("c-props")
|
||||
|
||||
# parse c-props tag to extract properties and defaults
|
||||
if c_props:
|
||||
props_with_defaults = []
|
||||
for prop, value in c_props.attrs.items():
|
||||
if value is None:
|
||||
props_with_defaults.append(f"{prop}={prop}")
|
||||
else:
|
||||
# Assuming value is already a string that represents the default value
|
||||
props_with_defaults.append(f'{prop}={prop}|default:"{value}"')
|
||||
|
||||
c_props.decompose()
|
||||
|
||||
# Construct the {% with %} opening tag
|
||||
opening = "{% cotton_props_frame " + " ".join(props_with_defaults) + " %}"
|
||||
closing = "{% endcotton_props_frame %}"
|
||||
|
||||
# Convert the remaining soup back to a string and wrap it within {% with %} block
|
||||
wrapped_content = opening + str(soup).strip() + closing
|
||||
|
||||
# Since we can't replace the soup object itself, we create new soup instead
|
||||
new_soup = BeautifulSoup(wrapped_content, "html.parser")
|
||||
|
||||
return new_soup
|
||||
|
||||
def _transform_named_slot(self, slot_tag, component_key):
|
||||
"""Replace <c-slot> tags with the {% cotton_slot %} template tag"""
|
||||
# for c_slot in soup.find_all("c-slot"):
|
||||
slot_name = slot_tag.get("name", "").strip()
|
||||
inner_html = "".join(str(content) for content in slot_tag.contents)
|
||||
|
||||
# Check and process any components in the slot content
|
||||
|
||||
slot_soup = BeautifulSoup(inner_html, "html.parser")
|
||||
self._transform_components(slot_soup, component_key)
|
||||
|
||||
cotton_slot_tag = f"{{% cotton_slot {slot_name} {component_key} %}}{str(slot_soup)}{{% end_cotton_slot %}}"
|
||||
|
||||
slot_tag.replace_with(BeautifulSoup(cotton_slot_tag, "html.parser"))
|
||||
|
||||
def _transform_components(self, soup, component_key):
|
||||
"""Replace <c-[component path]> tags with the {% cotton_component %} template tag"""
|
||||
for tag in soup.find_all(re.compile("^c-"), recursive=True):
|
||||
if tag.name == "c-slot":
|
||||
self._transform_named_slot(tag, component_key)
|
||||
|
||||
continue
|
||||
|
||||
component_name = tag.name[2:]
|
||||
|
||||
# Convert dot notation to path structure and replace hyphens with underscores
|
||||
path = component_name.replace(".", "/").replace("-", "_")
|
||||
|
||||
# Construct the opening tag
|
||||
opening_tag = f"{{% cotton_component {'cotton/{}.cotton.html'.format(path)} {component_name} "
|
||||
for attr, value in tag.attrs.items():
|
||||
if attr == "class":
|
||||
value = " ".join(value)
|
||||
opening_tag += ' {}="{}"'.format(attr, value)
|
||||
opening_tag += " %}"
|
||||
|
||||
# Construct the closing tag
|
||||
closing_tag = "{% end_cotton_component %}"
|
||||
|
||||
if tag.contents:
|
||||
tag_soup = BeautifulSoup(tag.decode_contents(), "html.parser")
|
||||
self._transform_components(tag_soup, component_name)
|
||||
|
||||
# Create new content with opening tag, tag content, and closing tag
|
||||
new_content = opening_tag + str(tag_soup) + closing_tag
|
||||
|
||||
else:
|
||||
# Create new content with opening tag and closing tag
|
||||
new_content = opening_tag + closing_tag
|
||||
|
||||
# Replace the original tag with the new content
|
||||
new_soup = BeautifulSoup(new_content, "html.parser")
|
||||
tag.replace_with(new_soup)
|
||||
|
||||
return soup
|
||||
|
||||
|
||||
class CottonTemplateCacheHandler:
|
||||
"""Handles caching of cotton templates so the html parsing is only done on first load of each view or component."""
|
||||
|
||||
def __init__(self):
|
||||
self.enabled = getattr(settings, "TEMPLATE_CACHING_ENABLED", True)
|
||||
|
||||
def get_cache_key(self, template_name, mtime):
|
||||
template_hash = hashlib.sha256(template_name.encode()).hexdigest()
|
||||
return f"cotton_cache_{template_hash}_{mtime}"
|
||||
|
||||
def get_cached_template(self, cache_key):
|
||||
if not self.enabled:
|
||||
return None
|
||||
return cache.get(cache_key)
|
||||
|
||||
def cache_template(self, cache_key, content, timeout=None):
|
||||
if self.enabled:
|
||||
cache.set(cache_key, content, timeout=timeout)
|
|
@ -0,0 +1,3 @@
|
|||
<c-merges-attributes class="extra-class" silica:model="test" another="test">
|
||||
ss
|
||||
</c-merges-attributes>
|
|
@ -0,0 +1,3 @@
|
|||
<c-receives-attributes attribute_1="hello" and-another="woo1" thirdForLuck="yes">
|
||||
ss
|
||||
</c-receives-attributes>
|
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-child>d</c-child>
|
||||
</c-parent>
|
|
@ -0,0 +1 @@
|
|||
<div class="i-am-child"></div>
|
|
@ -0,0 +1,11 @@
|
|||
<div>
|
||||
Header:
|
||||
{{ header }}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
Content:
|
||||
{{ slot }}
|
||||
</div>
|
||||
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
<div {{ attrs_dict|merge:'class:form-group another-class-with:colon' }}>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div>
|
||||
{{ name }}
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div class="i-am-parent">
|
||||
{{slot}}
|
||||
</div>
|
|
@ -0,0 +1,3 @@
|
|||
<div {{ attrs }}>
|
||||
|
||||
</div>
|
|
@ -0,0 +1,13 @@
|
|||
<c-props prop1 default_prop="default prop" />
|
||||
|
||||
<p>slot: '{{ slot }}'</p>
|
||||
|
||||
<p>attr1: '{{ attr1 }}'</p>
|
||||
<p>attr2: '{{ attr2 }}'</p>
|
||||
|
||||
<p>prop1: '{{ prop1 }}'</p>
|
||||
<p>default_prop: '{{ default_prop }}'</p>
|
||||
|
||||
<p>named_slot: '{{ named_slot }}'</p>
|
||||
|
||||
<p>attrs: '{{ attrs }}'</p>
|
|
@ -0,0 +1 @@
|
|||
<div class="{% if 1 < 2 %} some-class {% endif %}">Hello, World!</div>
|
|
@ -0,0 +1,3 @@
|
|||
<c-parent>
|
||||
<c-forms.input name="test" style="width: 100%" silica:model="first_name"/>
|
||||
</c-parent>
|
|
@ -0,0 +1,7 @@
|
|||
{% for item in items %}
|
||||
<c-named-slot-component>
|
||||
<c-slot name="name">
|
||||
item name: {{ item.name }}
|
||||
</c-slot>
|
||||
</c-named-slot-component>
|
||||
{% endfor %}
|
|
@ -0,0 +1 @@
|
|||
<c-parent></c-parent>
|
|
@ -0,0 +1,4 @@
|
|||
{% load static %}
|
||||
|
||||
|
||||
<c-parent/>
|
|
@ -0,0 +1,5 @@
|
|||
<c-test-component prop1="string with space" attr1="I have spaces">
|
||||
<c-slot name="named_slot">
|
||||
named_slot with spaces
|
||||
</c-slot>
|
||||
</c-test-component>
|
|
@ -0,0 +1,2 @@
|
|||
<c-test-component attr1="variable" :attr2="variable">
|
||||
</c-test-component>
|
0
docs/docs_project/django_cotton/templatetags/__init__.py
Normal file
0
docs/docs_project/django_cotton/templatetags/__init__.py
Normal file
68
docs/docs_project/django_cotton/templatetags/_component.py
Normal file
68
docs/docs_project/django_cotton/templatetags/_component.py
Normal file
|
@ -0,0 +1,68 @@
|
|||
from django import template
|
||||
from django.template import Node
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
|
||||
def cotton_component(parser, token):
|
||||
bits = token.split_contents()
|
||||
tag_name = bits[0]
|
||||
template_path = bits[1]
|
||||
component_key = bits[2]
|
||||
|
||||
kwargs = {}
|
||||
for bit in bits[3:]:
|
||||
key, value = bit.split("=")
|
||||
if key.startswith(":"): # Detect variables
|
||||
key = key[1:] # Remove ':' prefix
|
||||
value = value.strip("'\"") # Remove quotes
|
||||
kwargs[key] = template.Variable(value) # Treat as a variable
|
||||
else:
|
||||
kwargs[key] = value.strip("'\"") # Treat as a literal string
|
||||
|
||||
nodelist = parser.parse(("end_cotton_component",))
|
||||
parser.delete_first_token()
|
||||
|
||||
return CottonComponentNode(nodelist, template_path, component_key, kwargs)
|
||||
|
||||
|
||||
class CottonComponentNode(Node):
|
||||
def __init__(self, nodelist, template_path, component_key, kwargs):
|
||||
self.nodelist = nodelist
|
||||
self.template_path = template_path
|
||||
self.component_key = component_key
|
||||
self.kwargs = kwargs
|
||||
|
||||
def render(self, context):
|
||||
local_context = context.flatten()
|
||||
|
||||
attrs = {}
|
||||
for key, value in self.kwargs.items():
|
||||
if isinstance(value, template.Variable): # Resolve variables
|
||||
try:
|
||||
resolved_value = value.resolve(context)
|
||||
attrs[key] = resolved_value
|
||||
except template.VariableDoesNotExist:
|
||||
pass # Handle variable not found, if necessary
|
||||
else:
|
||||
attrs[key] = value # Use literal string
|
||||
|
||||
# Add the remainder as the default slot
|
||||
rendered = self.nodelist.render(context)
|
||||
local_context.update({"slot": rendered})
|
||||
|
||||
slots = context.get("cotton_slots", {})
|
||||
component_slots = slots.get(self.component_key, {})
|
||||
|
||||
local_context.update(component_slots)
|
||||
local_context.update(attrs)
|
||||
local_context.update({"attrs_dict": attrs})
|
||||
|
||||
rendered = render_to_string(self.template_path, local_context)
|
||||
|
||||
# Now reset the component's slots in context to prevent bleeding
|
||||
if self.component_key in slots:
|
||||
slots[self.component_key] = {}
|
||||
|
||||
context.update({"cotton_slots": slots})
|
||||
|
||||
return rendered
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue