From a9f15f5879f6f43a779af8ba5f20c957050aaac2 Mon Sep 17 00:00:00 2001 From: Archmonger <16909269+Archmonger@users.noreply.github.com> Date: Fri, 9 Feb 2024 19:26:13 -0800 Subject: [PATCH] Link Django CSS to components (CSS Modules) --- requirements/pkg-deps.txt | 1 + src/reactpy_django/css_modules.py | 124 ++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/reactpy_django/css_modules.py diff --git a/requirements/pkg-deps.txt b/requirements/pkg-deps.txt index c6102c18..e49f12d9 100644 --- a/requirements/pkg-deps.txt +++ b/requirements/pkg-deps.txt @@ -7,3 +7,4 @@ dill >=0.3.5 orjson >=3.6.0 nest_asyncio >=1.5.0 typing_extensions +tinycss2 >=1.0.2 diff --git a/src/reactpy_django/css_modules.py b/src/reactpy_django/css_modules.py new file mode 100644 index 00000000..e9263399 --- /dev/null +++ b/src/reactpy_django/css_modules.py @@ -0,0 +1,124 @@ +import re +from dataclasses import dataclass, field +from typing import Any, Literal, TypedDict + +import tinycss2 +from tinycss2.ast import QualifiedRule + +file_text = open("example.css", "r").read() + + +SquareBracketValues = TypedDict( + "SquareBracketValues", + {"key": str, "operator": str | None, "value": str | list | None}, +) + + +@dataclass +class CssRule: + """Represents a CSS selector. + Examples of the parser's targets are shown below as docstrings.""" + + tag: str | Literal["*"] | None = None + """div, span""" + id: str | None = None + """#id1, #id2""" + classes: list[str] = field(default_factory=list) + """.class1, .class2""" + attributes: list[SquareBracketValues] = field(default_factory=list) + """[key=value], [key^=value]""" + styles: dict[str, Any] = field(default_factory=dict) + """color: red;""" + psuedo_functions: list[str] = field(default_factory=list) + """:not(), :nth-child()""" + psuedo_elements: list[str] = field(default_factory=list) + """:hover, :focus""" + psuedo_classes: list[str] = field(default_factory=list) + """::before, ::after""" + + next: list["CssRule"] = field(default_factory=list) + """#current-selector #next-selector""" + next_operator: str = " " + """>, +, ~""" + + +@dataclass +class CssToPython: + """Performs a best-effort conversion of a CSS file into a list of Python dictionaries.""" + + content: str + + def convert(self) -> list[CssRule]: + """Converts the CSS file into a list of CSS rule dictionaries.""" + self.parsed: list[QualifiedRule] = tinycss2.parse_stylesheet( + self.content, + skip_comments=True, + skip_whitespace=True, + ) + self.selectors: list[CssRule] = [] + + for style in self.parsed: + if style.type != "qualified-rule": + continue + selector = CssRule() + + # Determine what CSS rules are defined in the selector + computed_rules = tinycss2.parse_declaration_list( + style.content, skip_comments=True, skip_whitespace=True + ) + for rule in computed_rules: + selector.styles[rule.name] = self._parse_rule_value(rule.value) + + # Parse HTML tag name from the selector + if style.prelude[0].type == "ident": + selector.tag = style.prelude[0].value + elif style.prelude[0].type == "literal" and style.prelude[0].value == "*": + selector.tag = "*" + + # Parse all other attributes from the selector + print(style.prelude) + for count, token in enumerate(style.prelude): + if token.type == "hash": + selector.id = selector.id or token.value + elif token.type == "literal" and token.value == ".": + selector.classes.append(style.prelude[count + 1].value) + elif token.type == "[] block" and len(token.content) == 1: + selector.attributes.append( + { + "key": token.content[0].value, + "operator": None, + "value": None, + } + ) + elif token.type == "[] block" and len(token.content) == 3: + selector.attributes.append( + { + "key": token.content[0].value, + "operator": token.content[1].value, + "value": token.content[2].value, + } + ) + # TODO: Once we reach an operator or whitespace, recursively parse the children then break + # TODO: Split comma operators into separate selectors + + self.selectors.append(selector) + + return self.selectors + + @staticmethod + def _flatten_whitespace(string: str) -> str: + return re.sub(r"\s+", " ", string).strip() + + def _parse_rule_value(self, rule): + """Parses a single TinyCSS rule and returns Python a data type.""" + rule = tinycss2.serialize(rule) + rule = self._flatten_whitespace(rule) + if rule.isnumeric(): + return int(rule) + else: + return rule + + +styles = CssToPython(file_text).convert() +for style in styles: + print(style)