mirror of
https://github.com/wrabit/django-cotton.git
synced 2025-08-04 15:18:20 +00:00
304 lines
12 KiB
Python
Executable file
304 lines
12 KiB
Python
Executable file
import warnings
|
|
import hashlib
|
|
import os
|
|
import re
|
|
|
|
from django.template.loaders.base import Loader as BaseLoader
|
|
from django.core.exceptions import SuspiciousFileOperation
|
|
from django.template import TemplateDoesNotExist
|
|
from bs4.formatter import HTMLFormatter
|
|
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.template_processor = CottonTemplateProcessor()
|
|
self.dirs = dirs
|
|
|
|
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)
|
|
|
|
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
|
|
|
|
template_string = self._get_template_string(origin.name)
|
|
compiled_template = self.template_processor.process(
|
|
template_string, origin.template_name
|
|
)
|
|
|
|
self.cache_handler.cache_template(cache_key, compiled_template)
|
|
|
|
return compiled_template
|
|
|
|
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_template_string(self, template_name):
|
|
try:
|
|
with open(template_name, "r") as f:
|
|
return f.read()
|
|
except FileNotFoundError:
|
|
raise TemplateDoesNotExist(template_name)
|
|
|
|
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."""
|
|
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,
|
|
)
|
|
|
|
|
|
class UnsortedAttributes(HTMLFormatter):
|
|
"""This keeps BS4 from re-ordering attributes"""
|
|
|
|
def attributes(self, tag):
|
|
for k, v in tag.attrs.items():
|
|
yield k, v
|
|
|
|
|
|
class CottonTemplateProcessor:
|
|
DJANGO_SYNTAX_PLACEHOLDER_PREFIX = "__django_syntax__"
|
|
COTTON_VERBATIM_PATTERN = re.compile(
|
|
r"\{% cotton_verbatim %\}(.*?)\{% endcotton_verbatim %\}", re.DOTALL
|
|
)
|
|
DJANGO_TAG_PATTERN = re.compile(r"\{%.*?%\}")
|
|
DJANGO_VAR_PATTERN = re.compile(r"\{\{.*?\}\}")
|
|
|
|
def __init__(self):
|
|
self.django_syntax_placeholders = []
|
|
|
|
def process(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)
|
|
return self._revert_bs4_attribute_empty_attribute_fixing(content)
|
|
|
|
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 = []
|
|
|
|
def replace_pattern(pattern, replacement_func):
|
|
return pattern.sub(replacement_func, content)
|
|
|
|
def replace_cotton_verbatim(match):
|
|
inner_content = match.group(1)
|
|
self.django_syntax_placeholders.append(inner_content)
|
|
return f"{self.DJANGO_SYNTAX_PLACEHOLDER_PREFIX}{len(self.django_syntax_placeholders)}__"
|
|
|
|
def replace_django_syntax(match):
|
|
self.django_syntax_placeholders.append(match.group(0))
|
|
return f"{self.DJANGO_SYNTAX_PLACEHOLDER_PREFIX}{len(self.django_syntax_placeholders)}__"
|
|
|
|
# Replace cotton_verbatim blocks
|
|
content = replace_pattern(self.COTTON_VERBATIM_PATTERN, replace_cotton_verbatim)
|
|
|
|
# Replace {% ... %}
|
|
content = replace_pattern(self.DJANGO_TAG_PATTERN, replace_django_syntax)
|
|
|
|
# Replace {{ ... }}
|
|
content = replace_pattern(self.DJANGO_VAR_PATTERN, replace_django_syntax)
|
|
|
|
return content
|
|
|
|
def _compile_cotton_to_django(self, html_content, component_key):
|
|
"""Convert cotton <c-* syntax to {%."""
|
|
soup = BeautifulSoup(html_content, "html.parser")
|
|
|
|
# check if soup contains a 'c-vars' tag
|
|
if cvars_el := soup.find("c-vars"):
|
|
soup = self._wrap_with_cotton_vars_frame(soup, cvars_el)
|
|
|
|
self._transform_components(soup, component_key)
|
|
|
|
return str(soup.encode(formatter=UnsortedAttributes()).decode("utf-8"))
|
|
|
|
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"{self.DJANGO_SYNTAX_PLACEHOLDER_PREFIX}{i}__", placeholder
|
|
)
|
|
|
|
return content
|
|
|
|
def _revert_bs4_attribute_empty_attribute_fixing(self, contents):
|
|
"""
|
|
Removes empty attribute values added by BeautifulSoup to Django template tags.
|
|
|
|
BeautifulSoup adds ="" to empty attribute-like parts in HTML-like nodes.
|
|
This method removes these additions for Django template tags.
|
|
|
|
Examples:
|
|
- <div {{ something }}=""> becomes <div {{ something }}>
|
|
- <div {% something %}=""> becomes <div {% something %}>
|
|
"""
|
|
# Remove ="" after Django variable tags
|
|
contents = contents.replace('}}=""', "}}")
|
|
|
|
# Remove ="" after Django template tags
|
|
contents = contents.replace('%}=""', "%}")
|
|
|
|
return contents
|
|
|
|
def _wrap_with_cotton_vars_frame(self, soup, cvars_el):
|
|
"""Wrap content with {% cotton_vars_frame %} to be able to govern vars and attributes. In order to recognise
|
|
vars defined in a component and also have them available in the same component's context, we wrap the entire
|
|
contents in another component: cotton_vars_frame."""
|
|
|
|
vars_with_defaults = []
|
|
for var, value in cvars_el.attrs.items():
|
|
# Attributes in context at this point will already have been formatted in _component to be accessible, so in order to cascade match the style.
|
|
accessible_var = var.replace("-", "_")
|
|
|
|
if value is None:
|
|
vars_with_defaults.append(f"{var}={accessible_var}")
|
|
elif var.startswith(":"):
|
|
# If ':' is present, the user wants to parse a literal string as the default value,
|
|
# i.e. "['a', 'b']", "{'a': 'b'}", "True", "False", "None" or "1".
|
|
var = var[1:] # Remove the ':' prefix
|
|
accessible_var = accessible_var[1:] # Remove the ':' prefix
|
|
vars_with_defaults.append(
|
|
f'{var}={accessible_var}|eval_default:"{value}"'
|
|
)
|
|
else:
|
|
# Assuming value is already a string that represents the default value
|
|
vars_with_defaults.append(f'{var}={accessible_var}|default:"{value}"')
|
|
|
|
cvars_el.decompose()
|
|
|
|
# Construct the {% with %} opening tag
|
|
opening = "{% cotton_vars_frame " + " ".join(vars_with_defaults) + " %}"
|
|
closing = "{% endcotton_vars_frame %}"
|
|
|
|
# Convert the remaining soup back to a string and wrap it within {% with %} block
|
|
wrapped_content = (
|
|
opening
|
|
+ str(soup.encode(formatter=UnsortedAttributes()).decode("utf-8")).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_components(self, soup, parent_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, parent_key)
|
|
|
|
continue
|
|
|
|
component_key = tag.name[2:]
|
|
component_path = component_key.replace(".", "/").replace("-", "_")
|
|
opening_tag = f"{{% cotton_component {'{}/{}.html'.format(settings.COTTON_DIR if hasattr(settings, 'COTTON_DIR') else 'cotton', component_path)} {component_key} "
|
|
|
|
# Store attributes that contain template expressions, they are when we use '{{' or '{%' in the value of an attribute
|
|
expression_attrs = []
|
|
|
|
# Build the attributes
|
|
for key, value in tag.attrs.items():
|
|
# BS4 stores class values as a list, so we need to join them back into a string
|
|
if key == "class":
|
|
value = " ".join(value)
|
|
|
|
# Django templates tags cannot have {{ or {% expressions in their attribute values
|
|
# Neither can they have new lines, let's treat them both as "expression attrs"
|
|
if self.DJANGO_SYNTAX_PLACEHOLDER_PREFIX in value or "\n" in value:
|
|
expression_attrs.append((key, value))
|
|
continue
|
|
|
|
opening_tag += ' {}="{}"'.format(key, value)
|
|
opening_tag += " %}"
|
|
|
|
component_tag = opening_tag
|
|
|
|
if expression_attrs:
|
|
for key, value in expression_attrs:
|
|
component_tag += f"{{% cotton_slot {key} {component_key} expression_attr %}}{value}{{% end_cotton_slot %}}"
|
|
|
|
if tag.contents:
|
|
tag_soup = BeautifulSoup(tag.decode_contents(), "html.parser")
|
|
self._transform_components(tag_soup, component_key)
|
|
component_tag += str(
|
|
tag_soup.encode(formatter=UnsortedAttributes()).decode("utf-8")
|
|
)
|
|
|
|
component_tag += "{% end_cotton_component %}"
|
|
|
|
# Replace the original tag with the compiled django syntax
|
|
new_soup = BeautifulSoup(component_tag, "html.parser")
|
|
tag.replace_with(new_soup)
|
|
|
|
return soup
|
|
|
|
def _transform_named_slot(self, slot_tag, component_key):
|
|
"""Compile <c-slot> to {% cotton_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.encode(formatter=UnsortedAttributes()).decode('utf-8'))}{{% end_cotton_slot %}}"
|
|
|
|
slot_tag.replace_with(BeautifulSoup(cotton_slot_tag, "html.parser"))
|
|
|
|
|
|
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, "COTTON_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)
|