This commit is contained in:
Will Abbott 2024-09-02 22:36:43 +01:00
parent fc4ba3f8d8
commit ff0e90b9fc
4 changed files with 121 additions and 45 deletions

View file

@ -9,12 +9,13 @@ from django.core.exceptions import SuspiciousFileOperation
from django.template import TemplateDoesNotExist, Origin
from django.utils._os import safe_join
from django.template import Template
from django.conf import settings
from django.apps import apps
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning
from bs4.formatter import HTMLFormatter
from django_cotton.utils import CottonHTMLTreeBuilder
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
@ -112,7 +113,6 @@ class CottonCompiler:
def process(self, content, template_name):
content = self._replace_syntax_with_placeholders(content)
content = self._compile_cotton_to_django(content, template_name)
content = self._fix_bs4_attribute_empty_attribute_behaviour(content)
content = self._replace_placeholders_with_syntax(content)
content = self._remove_duplicate_attribute_markers(content)
@ -163,11 +163,7 @@ class CottonCompiler:
def _compile_cotton_to_django(self, html_content, template_name):
"""Convert cotton <c-* syntax to {%."""
soup = BeautifulSoup(
html_content,
"html.parser",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
soup = get_bs4_instance(html_content)
# check if soup contains a 'c-vars' tag
if cvars_el := soup.find("c-vars"):
@ -202,13 +198,6 @@ class CottonCompiler:
def _remove_duplicate_attribute_markers(self, content):
return re.sub(r"__COTTON_DUPE_ATTR__[0-9A-F]{5}", "", content, flags=re.IGNORECASE)
def _fix_bs4_attribute_empty_attribute_behaviour(self, contents):
"""Bs4 adds ="" to valueless attribute-like parts in HTML tags that causes issues when we want to manipulate
django expressions."""
contents = contents.replace('=""', "")
return contents
def _wrap_with_cotton_vars_frame(self, soup, cvars_el):
"""If the user has defined a <c-vars> tag, wrap content with {% cotton_vars_frame %} to be able to create and
govern vars and attributes. To be able to defined new vars within a component and also have them available in the
@ -246,11 +235,7 @@ class CottonCompiler:
)
# Since we can't replace the soup object itself, we create new soup instead
new_soup = BeautifulSoup(
wrapped_content,
"html.parser",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
new_soup = get_bs4_instance(wrapped_content)
return new_soup
@ -271,6 +256,11 @@ class CottonCompiler:
# Build the attributes
for key, value in tag.attrs.items():
# value might be None
if value is None:
opening_tag += f" {key}"
continue
# BS4 stores class values as a list, so we need to join them back into a string
if key == "class":
value = " ".join(value)
@ -291,10 +281,8 @@ class CottonCompiler:
component_tag += f"{{% cotton_slot {key} {component_key} expression_attr %}}{value}{{% end_cotton_slot %}}"
if tag.contents:
tag_soup = BeautifulSoup(
tag_soup = get_bs4_instance(
tag.decode_contents(formatter=UnsortedAttributes()),
"html.parser",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
self._transform_components(tag_soup, component_key)
component_tag += str(
@ -304,11 +292,7 @@ class CottonCompiler:
component_tag += "{% end_cotton_component %}"
# Replace the original tag with the compiled django syntax
new_soup = BeautifulSoup(
component_tag,
"html.parser",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
new_soup = get_bs4_instance(component_tag)
tag.replace_with(new_soup)
return soup
@ -319,24 +303,15 @@ class CottonCompiler:
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",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
slot_soup = get_bs4_instance(inner_html)
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",
on_duplicate_attribute=self.handle_duplicate_attributes,
)
)
slot_tag.replace_with(get_bs4_instance(cotton_slot_tag))
@staticmethod
def get_bs4_instance(content):
def handle_duplicate_attributes(tag_attrs, key, value):
"""BS4 cleans html and removes duplicate attributes. This would be fine if our target was html, but actually
we're targeting Django Template Language. This contains expressions to govern content including attributes of
@ -346,11 +321,19 @@ class CottonCompiler:
The solution here is to make duplicate attribute keys unique across that tag so BS4 will not attempt to merge or
replace existing. Then in post processing we'll remove the unique mask.
Todo - This could be simplified with a custom formatter
"""
key_id = "".join(random.choice("0123456789ABCDEF") for i in range(5))
key = f"{key}__COTTON_DUPE_ATTR__{key_id}"
tag_attrs[key] = value
return BeautifulSoup(
content,
"html.parser",
builder=CottonHTMLTreeBuilder(on_duplicate_attribute=handle_duplicate_attributes),
)
class CottonTemplateCacheHandler:
"""This mimics the simple template caching mechanism in Django's cached.Loader. Django's cached.Loader is a bit

View file

@ -30,7 +30,7 @@ def cotton_component(parser, token):
except ValueError:
# No value provided, assume boolean attribute
key = bit
value = ""
value = True
kwargs[key] = value
@ -92,14 +92,12 @@ class CottonComponentNode(Node):
for key, value in self.kwargs.items():
# strip single or double quotes only if both sides have them
if value and value[0] == value[-1] and value[0] in ('"', "'"):
if isinstance(value, str) and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
if key.startswith(":"):
key = key[1:]
attrs[key] = self._process_dynamic_attribute(value, context)
elif value == "":
attrs[key] = True
else:
attrs[key] = value
@ -116,7 +114,7 @@ class CottonComponentNode(Node):
pass
# Boolean attribute?
if value == "":
if value is True:
return True
# Could be a string literal but process any template strings first to handle intermingled expressions

View file

@ -323,6 +323,34 @@ class InlineTestCase(CottonInlineTestCase):
response = self.client.get("/view/")
self.assertContains(response, "It's True")
def test_empty_strings_are_not_considered_booleans(self):
self.create_template(
"cotton/empty_string_attrs.html",
"""
{% if something1 == "" %}
I am string
{% endif %}
{% if something2 is True %}
I am boolean
{% endif %}
""",
)
self.create_template(
"empty_string_attrs_view.html",
"""
<c-empty-string-attrs something1="" something2 />
""",
"view/",
)
# Override URLconf
with self.settings(ROOT_URLCONF=self.get_url_conf()):
response = self.client.get("/view/")
self.assertContains(response, "I am string")
self.assertContains(response, "I am boolean")
class CottonTestCase(TestCase):
def test_parent_component_is_rendered(self):

View file

@ -1,5 +1,8 @@
import ast
from bs4.builder._htmlparser import BeautifulSoupHTMLParser, HTMLParserTreeBuilder
from html.parser import HTMLParser
def eval_string(value):
"""
@ -16,3 +19,67 @@ def ensure_quoted(value):
return value
else:
return f'"{value}"'
class CottonHTMLParser(BeautifulSoupHTMLParser):
def __init__(self, tree_builder, soup, on_duplicate_attribute):
# Initialize the parent class (HTMLParser) without additional arguments
HTMLParser.__init__(self)
self._first_processing_instruction = None
self.tree_builder = tree_builder
self.soup = soup
self._root_tag = None # Initialize _root_tag
self.already_closed_empty_element = [] # Initialize this list
self.on_duplicate_attribute = (
on_duplicate_attribute # You can set this according to your needs
)
self.IGNORE = "ignore"
self.REPLACE = "replace"
def handle_starttag(self, name, attrs, handle_empty_element=True):
"""Handle an opening tag, e.g. '<tag>'"""
attr_dict = {}
for key, value in attrs:
# START COTTON EDIT
# In cotton we want to preserve the intended value of
# the attribute from the developer so that we can differentiate
# boolean attributes and simply empty ones:
# if value is None:
# value = ''
# / END COTTON EDIT
if key in attr_dict:
on_dupe = self.on_duplicate_attribute
if on_dupe == self.IGNORE:
pass
elif on_dupe in (None, self.REPLACE):
attr_dict[key] = value
else:
on_dupe(attr_dict, key, value)
else:
attr_dict[key] = value
sourceline, sourcepos = self.getpos()
tag = self.soup.handle_starttag(
name, None, None, attr_dict, sourceline=sourceline, sourcepos=sourcepos
)
if tag and tag.is_empty_element and handle_empty_element:
self.handle_endtag(name, check_already_closed=False)
self.already_closed_empty_element.append(name)
if self._root_tag is None:
self._root_tag_encountered(name)
def _root_tag_encountered(self, name):
pass
class CottonHTMLTreeBuilder(HTMLParserTreeBuilder):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.handle_duplicate_attributes = kwargs.get("on_duplicate_attribute", None)
self.parser_class = CottonHTMLParser
def feed(self, markup):
parser = self.parser_class(self, self.soup, self.handle_duplicate_attributes)
parser.feed(markup)
parser.close()