webwidgets 1.0.0__py3-none-any.whl → 1.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
webwidgets/__init__.py CHANGED
@@ -10,7 +10,7 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
- __version__ = "1.0.0" # Dynamically set by build backend
13
+ __version__ = "1.1.0" # Dynamically set by build backend
14
14
 
15
15
  from . import compilation
16
16
  from . import utility
@@ -10,5 +10,6 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
- from .css import compile_css, CSSRule, CompiledCSS, apply_css, \
14
- default_rule_namer
13
+ from .css import apply_css, compile_css, CompiledCSS, default_class_namer
14
+ from .css_rule import ClassRule, CSSRule
15
+ from . import sections
@@ -10,88 +10,102 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
+ from .css_rule import ClassRule
13
14
  import itertools
15
+ from .sections.preamble import Preamble
16
+ from .sections.rule_section import RuleSection
14
17
  from typing import Callable, Dict, List, Union
15
18
  from webwidgets.compilation.html.html_node import HTMLNode
16
- from webwidgets.utility.indentation import get_indentation
17
19
  from webwidgets.utility.representation import ReprMixin
18
- from webwidgets.utility.validation import validate_css_identifier
19
-
20
-
21
- class CSSRule(ReprMixin):
22
- """A rule in a style sheet.
23
- """
24
-
25
- def __init__(self, name: str, declarations: Dict[str, str]):
26
- """Stores the name and declarations of the rule.
27
-
28
- :param name: The name of the rule.
29
- :type name: str
30
- :param declarations: The CSS declarations for the rule, specified as a
31
- dictionary where keys are property names and values are their
32
- corresponding values. For example: `{'color': 'red'}`
33
- :type declarations: Dict[str, str]
34
- """
35
- super().__init__()
36
- self.name = name
37
- self.declarations = declarations
38
20
 
39
21
 
40
22
  class CompiledCSS(ReprMixin):
41
23
  """A utility class to hold compiled CSS rules.
42
24
  """
43
25
 
44
- def __init__(self, trees: List[HTMLNode], rules: List[CSSRule],
45
- mapping: Dict[int, List[CSSRule]]):
46
- """Stores compiled CSS rules.
26
+ def __init__(self, trees: List[HTMLNode], core: RuleSection,
27
+ mapping: Dict[int, List[ClassRule]]):
28
+ """Stores compiled CSS rules and their mapping to the nodes in the
29
+ given trees.
47
30
 
48
31
  :param trees: The HTML trees at the origin of the compilation. These
49
32
  are the elements that have been styled with CSS properties.
50
33
  :type trees: List[HTMLNode]
51
- :param rules: The compiled CSS rules.
52
- :type rules: List[CSSRule]
34
+ :param rules: The CSS section containing the compiled CSS rules.
35
+ :type rules: RuleSection
53
36
  :param mapping: A dictionary mapping each node ID to a list of rules
54
37
  that achieve the same style.
55
- :type mapping: Dict[int, List[CSSRule]]
38
+ :type mapping: Dict[int, List[ClassRule]]
56
39
  """
57
40
  super().__init__()
58
41
  self.trees = trees
59
- self.rules = rules
42
+ self.preamble = Preamble()
43
+ self.core = core
60
44
  self.mapping = mapping
61
45
 
62
46
  def to_css(self, indent_size: int = 4) -> str:
63
- """Converts the `rules` dictionary of the :py:class:`CompiledCSS`
64
- object into CSS code.
47
+ """Converts the `preamble` and `core` sections of the
48
+ :py:class:`CompiledCSS` object into CSS code.
65
49
 
66
- Rule names are converted to class selectors. Note that each rule and
67
- property name is validated with :py:func:`validate_css_identifier`
68
- before being converted.
50
+ Sections are converted with their :py:meth:`RuleSection.to_css`
51
+ methods.
69
52
 
70
- :param indent_size: The number of spaces to use for indentation in the
71
- CSS code. Defaults to 4.
53
+ :param indent_size: See :py:meth:`RuleSection.to_css`.
72
54
  :type indent_size: int
73
55
  :return: The CSS code as a string.
74
56
  :rtype: str
75
57
  """
76
- # Initializing code and defining indentation
77
- css_code = ""
78
- indentation = get_indentation(level=1, size=indent_size)
58
+ return '\n\n'.join(
59
+ section.to_css(indent_size=indent_size) for section in (
60
+ self.preamble, self.core
61
+ ))
79
62
 
80
- # Writing down each rule
81
- for i, rule in enumerate(self.rules):
82
- validate_css_identifier(rule.name)
83
- css_code += f".{rule.name}" + " {\n"
84
- for property_name, value in rule.declarations.items():
85
- validate_css_identifier(property_name)
86
- css_code += f"{indentation}{property_name}: {value};\n"
87
- css_code += "}" + ('\n\n' if i < len(self.rules) - 1 else '')
88
63
 
89
- return css_code
64
+ def apply_css(css: CompiledCSS, tree: HTMLNode) -> None:
65
+ """Applies the CSS rules to the given tree.
66
+
67
+ Rules are added as HTML classes to each node with a style in the tree. If a
68
+ node does not have a `class` attribute yet, it will be created for that
69
+ node. Nodes that do not have any style are left untouched.
70
+
71
+ Note that this function is recursive and calls itself on each child node of
72
+ the tree.
73
+
74
+ :param css: The compiled CSS object containing the rules to apply and the
75
+ mapping to each node. It should have been created by invoking
76
+ :py:func:`compile_css` on the given tree, but it can be modified before
77
+ passing it to this function, as long as its content remains consistent.
78
+ :type css: CompiledCSS
79
+ :param tree: The tree to which the CSS rules should be applied. It will be
80
+ modified in place by this function. If you want to keep the original
81
+ tree unchanged, make a deep copy of it using its
82
+ :py:meth:`HTMLNode.copy` method and pass this copy instead.
83
+ :type tree: HTMLNode
84
+ """
85
+ # Only modifying nodes if they have a style (and therefore if the list of
86
+ # rules mapped to them in `css.mapping` is not empty)
87
+ if tree.style:
88
+
89
+ # Listing rules to add as classes. We do not add rules that are already
90
+ # there.
91
+ rules_to_add = [r.name for r in css.mapping[id(tree)] if r.name not in
92
+ tree.attributes.get('class', '').split(' ')]
93
+
94
+ # Updating the class attribute. If it already exists and is not empty,
95
+ # we need to insert a space before adding the CSS classes.
96
+ maybe_space = ' ' if tree.attributes.get(
97
+ 'class', None) and rules_to_add else ''
98
+ tree.attributes['class'] = tree.attributes.get(
99
+ 'class', '') + maybe_space + ' '.join(rules_to_add)
100
+
101
+ # Recursively applying the CSS rules to all child nodes of the tree
102
+ for child in tree.children:
103
+ apply_css(css, child)
90
104
 
91
105
 
92
106
  def compile_css(trees: Union[HTMLNode, List[HTMLNode]],
93
- rule_namer: Callable[[List[CSSRule], int],
94
- str] = None) -> CompiledCSS:
107
+ class_namer: Callable[[List[ClassRule], int],
108
+ str] = None) -> CompiledCSS:
95
109
  """Computes optimized CSS rules from the given HTML trees.
96
110
 
97
111
  The main purpose of this function is to reduce the number of CSS rules
@@ -118,36 +132,44 @@ def compile_css(trees: Union[HTMLNode, List[HTMLNode]],
118
132
  .. code-block:: python
119
133
 
120
134
  >>> compiled_css = compile_css(tree)
121
- >>> print(compiled_css.rules)
135
+ >>> print(compiled_css.core.rules)
122
136
  [
123
- CSSRule(name='r0', declarations={'color': 'blue'}),
124
- CSSRule(name='r1', declarations={'margin': '0'}),
125
- CSSRule(name='r2', declarations={'padding': '0'})
137
+ ClassRule(selector='.c0', declarations={'color': 'blue'}, ...),
138
+ ClassRule(selector='.c1', declarations={'margin': '0'}, ...),
139
+ ClassRule(selector='.c2', declarations={'padding': '0'}, ...)
126
140
  ]
127
141
 
142
+ Internally, each optimized rule gets compiled into a :py:class:`ClassRule`
143
+ object, which represents a CSS rule whose selector targets the HTML `class`
144
+ attribute. Each rule gets assigned a unique HTML class and all classes can
145
+ then be added to the trees with :py:func:`apply_css`. Classes are named
146
+ `"c0"`, `"c1"`, and so on by default, but this naming process can be
147
+ customized using the `class_namer` argument.
148
+
128
149
  :param trees: A single tree or a list of trees to optimize over. All
129
150
  children are recursively included in the compilation.
130
151
  :type trees: Union[HTMLNode, List[HTMLNode]]
131
- :param rule_namer: A callable that takes two arguments, which are the list
152
+ :param class_namer: A callable that takes two arguments, which are the list
132
153
  of all compiled rules and an index within that list, and returns a
133
- unique name for the rule at the given index.
134
-
135
- This argument allows to customize the rule naming process and use names
136
- other than the default `"r0"`, `"r1"`, etc. For example, it can be used
137
- to achieve something similar to Tailwind CSS and name rules according
138
- to what they achieve, e.g. by prefixing their name with `"m"` for
139
- margin rules or `"p"` for padding rules. Note that all rule names will
140
- be validated with the :py:func:`validate_css_identifier` function
141
- before being written into CSS code.
142
-
143
- Defaults to the :py:func:`default_rule_namer` function which implements
144
- a default naming strategy where each rule is named `"r{i}"` where `i`
145
- is the index of the rule in the list.
146
- :type rule_namer: Callable[[List[CSSRule], int], str]
154
+ unique name for the HTML class to associate with the rule at the given
155
+ index.
156
+
157
+ This argument allows to customize the class naming process and use names
158
+ other than the default `"c0"`, `"c1"`, etc. For example, it can be used
159
+ to achieve something similar to Tailwind CSS and name HTML classes
160
+ according to what they achieve, e.g. by prefixing their name with `"m"`
161
+ for margin rules or `"p"` for padding rules. Note that all class
162
+ selectors will be validated with the :py:func:`validate_css_selector`
163
+ function before being written into CSS code.
164
+
165
+ Defaults to the :py:func:`default_class_namer` function which
166
+ implements a default naming strategy where each class is named `"c{i}"`
167
+ where `i` is the index of the rule in the list.
168
+ :type class_namer: Callable[[List[ClassRule], int], str]
147
169
  :return: The :py:class:`CompiledCSS` object containing the optimized rules.
148
170
  Every HTML node present in one or more of the input trees is included
149
171
  in the :py:attr:`CompiledCSS.mapping` attribute, even if the node does
150
- not have a style. Rules are alphabetically ordered by name in the
172
+ not have a style. Rules are alphabetically ordered by class name in the
151
173
  mapping.
152
174
  :rtype: CompiledCSS
153
175
  """
@@ -155,76 +177,37 @@ def compile_css(trees: Union[HTMLNode, List[HTMLNode]],
155
177
  if isinstance(trees, HTMLNode):
156
178
  trees = [trees]
157
179
 
158
- # Handling default rule_namer
159
- rule_namer = default_rule_namer if rule_namer is None else rule_namer
180
+ # Handling default class_namer
181
+ class_namer = default_class_namer if class_namer is None else class_namer
160
182
 
161
183
  # We compute a simple mapping where each CSS property defines its own
162
184
  # ruleset
163
185
  styles = {k: v for tree in trees for k, v in tree.get_styles().items()}
164
186
  properties = set(itertools.chain.from_iterable(s.items()
165
187
  for s in styles.values()))
166
- rules = [CSSRule(None, dict([p])) # Initializing with no name
188
+ rules = [ClassRule("", dict([p])) # Initializing with empty name
167
189
  for p in sorted(properties)]
168
190
  for i, rule in enumerate(rules): # Assigning name from callback
169
- rule.name = rule_namer(rules, i)
191
+ rule.name = class_namer(rules, i)
170
192
  rules = sorted(rules, key=lambda r: r.name) # Sorting by name
171
193
  mapping = {node_id: [r for r in rules if
172
194
  set(r.declarations.items()).issubset(style.items())]
173
195
  for node_id, style in styles.items()}
174
- return CompiledCSS(trees, rules, mapping)
175
-
176
-
177
- def apply_css(css: CompiledCSS, tree: HTMLNode) -> None:
178
- """Applies the CSS rules to the given tree.
179
-
180
- Rules are added as HTML classes to each node with a style in the tree. If a
181
- node does not have a `class` attribute yet, it will be created for that
182
- node. Nodes that do not have any style are left untouched.
183
-
184
- Note that this function is recursive and calls itself on each child node of
185
- the tree.
186
-
187
- :param css: The compiled CSS object containing the rules to apply and the
188
- mapping to each node. It should have been created by invoking
189
- :py:func:`compile_css` on the given tree, but it can be modified before
190
- passing it to this function, as long as its content remains consistent.
191
- :type css: CompiledCSS
192
- :param tree: The tree to which the CSS rules should be applied. It will be
193
- modified in place by this function. If you want to keep the original
194
- tree unchanged, make a deep copy of it using its
195
- :py:meth:`HTMLNode.copy` method and pass this copy instead.
196
- :type tree: HTMLNode
197
- """
198
- # Only modifying nodes if they have a style (and therefore if the list of
199
- # rules mapped to them in `css.mapping` is not empty)
200
- if tree.style:
201
-
202
- # Listing rules to add as classes. We do not add rules that are already
203
- # there.
204
- rules_to_add = [r.name for r in css.mapping[id(tree)] if r.name not in
205
- tree.attributes.get('class', '').split(' ')]
206
196
 
207
- # Updating the class attribute. If it already exists and is not empty,
208
- # we need to insert a space before adding the CSS classes.
209
- maybe_space = ' ' if tree.attributes.get(
210
- 'class', None) and rules_to_add else ''
211
- tree.attributes['class'] = tree.attributes.get(
212
- 'class', '') + maybe_space + ' '.join(rules_to_add)
213
-
214
- # Recursively applying the CSS rules to all child nodes of the tree
215
- for child in tree.children:
216
- apply_css(css, child)
197
+ # Packaging the results into a CompiledCSS object
198
+ core = RuleSection(rules=rules, title="Core")
199
+ return CompiledCSS(trees, core, mapping)
217
200
 
218
201
 
219
- def default_rule_namer(rules: List[CSSRule], index: int) -> str:
220
- """Default rule naming function. Returns a string like "r{i}" where {i} is
202
+ def default_class_namer(rules: List[ClassRule], index: int) -> str:
203
+ """Default class naming function. Returns a string like "c{i}" where {i} is
221
204
  the index of the rule.
222
205
 
223
- :param rules: List of all compiled CSSRule objects. This argument is not
206
+ :param rules: List of all compiled ClassRule objects. This argument is not
224
207
  used in this function, but it can be used in other naming strategies.
225
- :type rules: List[CSSRule]
226
- :param index: Index of the rule being named.
208
+ :type rules: List[ClassRule]
209
+ :param index: Index of the rule whose class is being named.
227
210
  :type index: int
228
- :return: A string like `"r{i}"` where `i` is the index of the rule.
211
+ :return: A string like `"c{i}"` where `i` is the index of the rule.
229
212
  """
230
- return f'r{index}'
213
+ return f'c{index}'
@@ -0,0 +1,104 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from typing import Dict
14
+ from webwidgets.utility.indentation import get_indentation
15
+ from webwidgets.utility.representation import ReprMixin
16
+ from webwidgets.utility.validation import validate_css_identifier, validate_css_selector
17
+
18
+
19
+ class CSSRule(ReprMixin):
20
+ """A rule in a style sheet.
21
+ """
22
+
23
+ def __init__(self, selector: str, declarations: Dict[str, str]):
24
+ """Stores the selector and declarations of the rule.
25
+
26
+ :param selector: The selector of the rule.
27
+ :type selector: str
28
+ :param declarations: The CSS declarations for the rule, specified as a
29
+ dictionary where keys are property names and values are their
30
+ corresponding values. For example: `{'color': 'red'}`
31
+ :type declarations: Dict[str, str]
32
+ """
33
+ super().__init__()
34
+ self.selector = selector
35
+ self.declarations = declarations
36
+
37
+ def to_css(self, indent_size: int = 4) -> str:
38
+ """Converts the rule into CSS code.
39
+
40
+ The rule's name is converted to a class selector.
41
+
42
+ Note that the rule's name and all property names are validated before
43
+ being converted. The rule's name is validated with
44
+ :py:func:`validate_css_selector` while the property names are validated
45
+ with :py:func:`validate_css_identifier`.
46
+
47
+ :param indent_size: The number of spaces to use for indentation in the
48
+ CSS code. Defaults to 4.
49
+ :type indent_size: int
50
+ :return: The CSS code as a string.
51
+ :rtype: str
52
+ """
53
+ # Defining indentation
54
+ indentation = get_indentation(level=1, size=indent_size)
55
+
56
+ # Validating the selector
57
+ validate_css_selector(self.selector)
58
+
59
+ # Writing down each property
60
+ css_code = self.selector + " {\n"
61
+ for property_name, value in self.declarations.items():
62
+ validate_css_identifier(property_name)
63
+ css_code += f"{indentation}{property_name}: {value};\n"
64
+ css_code += "}"
65
+
66
+ return css_code
67
+
68
+
69
+ class ClassRule(CSSRule):
70
+ """A CSS rule that targets a CSS class.
71
+
72
+ The class dynamically sets its selector based on its class name.
73
+ """
74
+
75
+ def __init__(self, name: str, declarations: Dict[str, str]):
76
+ """Creates a new CSS class rule.
77
+
78
+ :param name: The name of the CSS class.
79
+ :type name: str
80
+ :param declarations: See :py:meth:`CSSRule.__init__`.
81
+ :type declarations: Dict[str, str]
82
+ """
83
+ super().__init__(None, declarations) # Starting without a selector
84
+ self._name = None # Starting without a name
85
+ self.name = name # Setting both the selector and the name here
86
+
87
+ @property
88
+ def name(self) -> str:
89
+ """Returns the name of the CSS class.
90
+
91
+ :return: The name of the CSS class.
92
+ :rtype: str
93
+ """
94
+ return self._name
95
+
96
+ @name.setter
97
+ def name(self, value: str) -> None:
98
+ """Sets the name of the CSS class.
99
+
100
+ :param value: The new name of the CSS class.
101
+ :type value: str
102
+ """
103
+ self._name = value
104
+ self.selector = f".{value}" # Updating the selector
@@ -0,0 +1,14 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from .preamble import Preamble
14
+ from .rule_section import RuleSection
@@ -0,0 +1,106 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import Any
15
+ from webwidgets.utility.representation import ReprMixin
16
+ from webwidgets.utility.validation import validate_css_comment
17
+
18
+
19
+ class CSSSection(ABC, ReprMixin):
20
+ """Abstract base class representing a section of a CSS file.
21
+
22
+ All subclasses of :py:class:`CSSSection` must implement a
23
+ :py:meth:`compile_content` method that returns a string.
24
+ """
25
+
26
+ @staticmethod
27
+ def prettify_title(title: str, min_length: int) -> str:
28
+ """Returns a prettified version of the given title with decorative
29
+ characters `=` around it.
30
+
31
+ This function will add the minimum number of decorative characters to
32
+ the title while keeping symmetry and remaining over the given minimum
33
+ length. In particular, if the given title is already above the minimum
34
+ length, this function will return it as is.
35
+
36
+ :param title: The title to prettify.
37
+ :type title: str
38
+ :param max_length: The minimum length of the prettified title.
39
+ :type max_length: int
40
+ :return: The prettified title.
41
+ :rtype: str
42
+ """
43
+ # If the title is already above min_length, we don't add decorative
44
+ # characters
45
+ if len(title) >= min_length:
46
+ return title
47
+
48
+ # Otherwise, we add decorative characters around the title
49
+ remaining = min_length - len(title)
50
+ characters = "=" * max(((remaining - 1) // 2), 1)
51
+ return characters + ' ' + title + ' ' + characters
52
+
53
+ def __init__(self, title: str = None):
54
+ """Creates a new section with an optional title.
55
+
56
+ :param title: The title of the section. If provided, the section will
57
+ be preceded by a comment containing the title in the output CSS
58
+ code. If None, no title will be used to separate the section from
59
+ the rest of the code.
60
+ :type title: str
61
+ """
62
+ super().__init__()
63
+ self.title = title
64
+
65
+ @abstractmethod
66
+ def compile_content(self) -> str:
67
+ """Converts the content of the CSSSection object (excluding the title)
68
+ into CSS code.
69
+
70
+ This method must be overridden by subclasses to compile specific CSS
71
+ code.
72
+ """
73
+ pass
74
+
75
+ def to_css(self, *args: Any, **kwargs: Any) -> str:
76
+ """Converts the CSSSection object into CSS code.
77
+
78
+ If the section has a title, it will be prettified with
79
+ :py:meth:`CSSSection.prettify_title` and turned into a comment. That
80
+ comment will be validated with :py:func:`validate_css_comment` and
81
+ inserted before the result of :py:meth:`CSSSection.compile_content` in
82
+ the CSS code.
83
+
84
+ If the section has no title, this function will produce the same result
85
+ as :py:meth:`CSSSection.compile_content`.
86
+
87
+ :param args: Arguments to pass to
88
+ :py:meth:`CSSSection.compile_content`.
89
+ :type args: Any
90
+ :param kwargs: Keyword arguments to pass to
91
+ :py:meth:`CSSSection.compile_content`.
92
+ :type kwargs: Any
93
+ :return: The CSS code for the section.
94
+ :rtype: str
95
+ """
96
+ # If no title, we just return the compiled content
97
+ if self.title is None:
98
+ return self.compile_content(*args, **kwargs)
99
+
100
+ # Otherwise, we turn the title into a comment and validate it
101
+ comment = ' ' + CSSSection.prettify_title(self.title, 40) + ' '
102
+ validate_css_comment(comment)
103
+
104
+ # Adding the comment before the compiled content
105
+ return "/*" + comment + "*/\n\n" + \
106
+ self.compile_content(*args, **kwargs)
@@ -0,0 +1,40 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from .rule_section import RuleSection
14
+ from webwidgets.compilation.css.css_rule import CSSRule
15
+
16
+
17
+ class Preamble(RuleSection):
18
+ """A set of CSS rules that apply globally to all HTML elements.
19
+
20
+ The CSS preamble serves as a global default for multiple properties. It is
21
+ used to define the document's box model and set all margin and padding
22
+ values to 0.
23
+ """
24
+
25
+ def __init__(self):
26
+ """Creates a new CSS preamble."""
27
+ super().__init__(
28
+ rules=[
29
+ CSSRule("*, *::before, *::after", {
30
+
31
+ # Defining the box model to border-box
32
+ "box-sizing": "border-box",
33
+
34
+ # Setting all margin and padding values to 0
35
+ "margin": "0",
36
+ "padding": "0"
37
+ })
38
+ ],
39
+ title="Preamble"
40
+ )
@@ -0,0 +1,43 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from .css_section import CSSSection
14
+ from typing import List
15
+ from webwidgets.compilation.css.css_rule import CSSRule
16
+
17
+
18
+ class RuleSection(CSSSection):
19
+ """A section containing a set of CSS rules.
20
+ """
21
+
22
+ def __init__(self, rules: List[CSSRule] = None, title: str = None):
23
+ """Creates a new section with the given rules and title.
24
+
25
+ :param rules: A list of CSSRule objects to include in the section.
26
+ :type rules: List[CSSRule]
27
+ :param title: The title of the section.
28
+ :type title: str
29
+ """
30
+ super().__init__(title=title)
31
+ self.rules = [] if rules is None else rules
32
+
33
+ def compile_content(self, indent_size: int = 4) -> str:
34
+ """Compiles the CSS representation of the rules contained in the
35
+ section.
36
+
37
+ :param indent_size: See :py:meth:`CSSRule.to_css`.
38
+ :type indent_size: int
39
+ :return: The CSS representation of the rules.
40
+ :rtype: str
41
+ """
42
+ return "\n\n".join([
43
+ rule.to_css(indent_size=indent_size) for rule in self.rules])
@@ -12,4 +12,4 @@
12
12
 
13
13
  from .html_node import HTMLNode, no_start_tag, no_end_tag, one_line, RawText, \
14
14
  RootNode
15
- from .html_tags import TextNode
15
+ from .html_tags import *
@@ -45,6 +45,11 @@ class Body(HTMLNode):
45
45
  pass
46
46
 
47
47
 
48
+ class Div(HTMLNode):
49
+ """A `<div>` element used for grouping elements."""
50
+ pass
51
+
52
+
48
53
  @one_line
49
54
  @no_end_tag
50
55
  class Doctype(HTMLNode):
@@ -14,6 +14,34 @@ from typing import *
14
14
  import re
15
15
 
16
16
 
17
+ # CSS selectors that are considered valid as selectors but not as identifiers
18
+ # according to the `validate_css_identifier()` function.
19
+ SPECIAL_SELECTORS = [
20
+ "*", "*::before", "*::after"
21
+ ]
22
+
23
+
24
+ def validate_css_comment(comment: str) -> None:
25
+ """Validates that the given comment is a valid CSS comment according to the
26
+ CSS syntax rules and raises an exception if not.
27
+
28
+ This function just checks that the comment does not contain any closing
29
+ sequence `*/` as defined in the CSS Syntax Module Level 3, paragraph 4.3.2
30
+ (see source: https://www.w3.org/TR/css-syntax-3/#consume-comment).
31
+
32
+ :param comment: The CSS comment to validate, without its opening and
33
+ closing sequences. It can include any number of opening sequences
34
+ (`/*`) as part of its content, in which case it is still a valid
35
+ comment per the CSS specification, but it cannot contain any closing
36
+ sequences (`*/`).
37
+ :type comment: str
38
+ :raises ValueError: If the comment is not a valid CSS comment.
39
+ """
40
+ if "*/" in comment:
41
+ raise ValueError(
42
+ f"Invalid CSS comment: '{comment}' contains closing sequence '*/'")
43
+
44
+
17
45
  def validate_css_identifier(identifier: str) -> None:
18
46
  """Checks if the given identifier is a valid identifier token according to
19
47
  the CSS syntax rules and raises an exception if not.
@@ -57,6 +85,45 @@ def validate_css_identifier(identifier: str) -> None:
57
85
  "allowed.")
58
86
 
59
87
 
88
+ def validate_css_selector(selector: str) -> None:
89
+ """Checks if the given CSS selector is valid and raises an exception if
90
+ not.
91
+
92
+ To be valid, the selector must either be:
93
+ - a special selector, which is defined as either `*`, `*::before`, or
94
+ `*::after`
95
+ - any combination of special selectors separated by a comma and a single
96
+ space (e.g. `*::before, *::after`)
97
+ - or a class selector, which is defined as a dot `.` followed by a valid
98
+ CSS identifier, as defined and enforced by the
99
+ :py:func:`validate_css_identifier` function
100
+
101
+ Note that this function imposes stricter rules than the official CSS
102
+ Selector Level 4 specification (see source:
103
+ https://www.w3.org/TR/selectors-4/). For example, this function does not
104
+ allow selectors with the relational pseudo-class `:has()` whereas the
105
+ specification does.
106
+
107
+ :param selector: The CSS selector to validate.
108
+ :type selector: str
109
+ :raises ValueError: If the selector is not a special selector nor a valid
110
+ CSS identifier.
111
+ """
112
+ # Checking if the selector is a special selector
113
+ if selector in SPECIAL_SELECTORS:
114
+ return
115
+
116
+ # Checking if the selector is a combination of special selectors
117
+ if all(part in SPECIAL_SELECTORS for part in selector.split(", ")):
118
+ return
119
+
120
+ # Otherwise, checking if the selector is a class selector
121
+ if not selector.startswith("."):
122
+ raise ValueError("Class selector must start with '.' but got: "
123
+ f"{selector}")
124
+ validate_css_identifier(selector[1:])
125
+
126
+
60
127
  def validate_html_class(class_attribute: str) -> None:
61
128
  """Checks if the given HTML class attribute is valid and raises an
62
129
  exception if not.
@@ -65,7 +132,7 @@ def validate_html_class(class_attribute: str) -> None:
65
132
  - the class attribute cannot start nor end with a space
66
133
  - the class attribute cannot contain double spaces
67
134
  - each class in the attribute must be a valid CSS identifier, as validated
68
- by the :py:func:`validate_css_identifier` function.
135
+ by the :py:func:`validate_css_identifier` function
69
136
 
70
137
  Note that this function imposes stricter rules than rule 2.3.7 of the HTML5
71
138
  specification (see source:
@@ -12,7 +12,7 @@
12
12
 
13
13
  from .compiled_website import CompiledWebsite
14
14
  from typing import Any, Callable, List
15
- from webwidgets.compilation.css import compile_css, CSSRule, apply_css
15
+ from webwidgets.compilation.css import apply_css, compile_css, ClassRule
16
16
  from webwidgets.utility.representation import ReprMixin
17
17
  from webwidgets.widgets.containers.page import Page
18
18
 
@@ -44,7 +44,7 @@ class Website(ReprMixin):
44
44
  force_one_line: bool = False,
45
45
  indent_level: int = 0,
46
46
  indent_size: int = 4,
47
- rule_namer: Callable[[List[CSSRule], int], str] = None,
47
+ class_namer: Callable[[List[ClassRule], int], str] = None,
48
48
  **kwargs: Any) -> CompiledWebsite:
49
49
  """Compiles the website into HTML and CSS code.
50
50
 
@@ -59,8 +59,8 @@ class Website(ReprMixin):
59
59
  :param indent_size: See :py:meth:`HTMLNode.to_html` and
60
60
  :py:meth:`CompiledCSS.to_css`.
61
61
  :type indent_size: int
62
- :param rule_namer: See :py:func:`compile_css`.
63
- :type rule_namer: Callable[[List[CSSRule], int], str]
62
+ :param class_namer: See :py:func:`compile_css`.
63
+ :type class_namer: Callable[[List[ClassRule], int], str]
64
64
  :param kwargs: See :py:meth:`HTMLNode.to_html`.
65
65
  :type kwargs: Any
66
66
  :return: A new :py:class:`CompiledWebsite` object containing the
@@ -72,7 +72,7 @@ class Website(ReprMixin):
72
72
  for page in self.pages]
73
73
 
74
74
  # Compiling HTML and CSS code
75
- compiled_css = compile_css(trees, rule_namer)
75
+ compiled_css = compile_css(trees, class_namer)
76
76
  for tree in trees:
77
77
  apply_css(compiled_css, tree)
78
78
  html_content = [tree.to_html(
@@ -10,5 +10,6 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
+ from .box import Box, Direction
13
14
  from .container import Container
14
15
  from .page import Page
@@ -0,0 +1,70 @@
1
+ # =======================================================================
2
+ #
3
+ # This file is part of WebWidgets, a Python package for designing web
4
+ # UIs.
5
+ #
6
+ # You should have received a copy of the MIT License along with
7
+ # WebWidgets. If not, see <https://opensource.org/license/mit>.
8
+ #
9
+ # Copyright(C) 2025, mlaasri
10
+ #
11
+ # =======================================================================
12
+
13
+ from .container import Container
14
+ from enum import auto, Enum
15
+ from webwidgets.compilation.html.html_tags import Div
16
+
17
+
18
+ class Direction(Enum):
19
+ HORIZONTAL = auto()
20
+ VERTICAL = auto()
21
+
22
+
23
+ class Box(Container):
24
+ """A widget that lays out its child widgets inside a row or a column.
25
+ """
26
+
27
+ def __init__(self, direction: Direction):
28
+ """Creates a new Box with the given direction.
29
+
30
+ :param direction: The direction in which the child widgets should be
31
+ laid out. Can be either `Direction.HORIZONTAL` or
32
+ `Direction.VERTICAL`.
33
+ :type direction: Direction
34
+ """
35
+ super().__init__()
36
+ self.direction = direction
37
+
38
+ def build(self):
39
+ """Builds the HTML representation of the Box.
40
+
41
+ The box is constructed as a `<div>` element with a flexbox layout. Its
42
+ `flex-direction` property is set to either "row" or "column" based on
43
+ the direction parameter, and it has a `data-role` attribute of "box".
44
+
45
+ Each child widget is wrapped inside its own `<div>` element with a
46
+ `data-role` attribute of "box-item". The items are centered within
47
+ their own `<div>`.
48
+ """
49
+ # Building child nodes
50
+ nodes = [w.build() for w in self.widgets]
51
+
52
+ # Building box items that wrap around child nodes
53
+ items = [Div(
54
+ children=[node],
55
+ attributes={"data-role": "box-item"},
56
+ style={
57
+ "display": "flex",
58
+ "flex-direction": "row",
59
+ "align-items": "center",
60
+ "justify-content": "center",
61
+ "flex-grow": "1"
62
+ }) for node in nodes]
63
+
64
+ # Assembling the box
65
+ flex_dir = "row" if self.direction == Direction.HORIZONTAL else "column"
66
+ box = Div(children=items, attributes={"data-role": "box"}, style={
67
+ "display": "flex",
68
+ "flex-direction": flex_dir
69
+ })
70
+ return box
@@ -16,8 +16,7 @@ from webwidgets.utility.representation import ReprMixin
16
16
 
17
17
 
18
18
  class Widget(ABC, ReprMixin):
19
- """
20
- Abstract base class for all widgets.
19
+ """Abstract base class for all widgets.
21
20
 
22
21
  All subclasses of :py:class:`Widget` must implement a :py:meth:`build`
23
22
  method that returns an :py:class:`HTMLNode` object.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webwidgets
3
- Version: 1.0.0
3
+ Version: 1.1.0
4
4
  Summary: A Python package for designing web UIs.
5
5
  Project-URL: Source code, https://github.com/mlaasri/WebWidgets
6
6
  Author: mlaasri
@@ -9,6 +9,11 @@ Keywords: design,webui
9
9
  Classifier: Operating System :: OS Independent
10
10
  Classifier: Programming Language :: Python :: 3
11
11
  Requires-Python: >=3.9
12
+ Provides-Extra: dev
13
+ Requires-Dist: numpy; extra == 'dev'
14
+ Requires-Dist: pillow; extra == 'dev'
15
+ Requires-Dist: pytest; extra == 'dev'
16
+ Requires-Dist: selenium; extra == 'dev'
12
17
  Description-Content-Type: text/markdown
13
18
 
14
19
  # WebWidgets
@@ -0,0 +1,30 @@
1
+ webwidgets/__init__.py,sha256=XGu3i9D0Zs9W12E62C5Yq-Aejh_GTlyz6197FAoxiME,549
2
+ webwidgets/compilation/__init__.py,sha256=hb61nhmPTghIzuA_hun98xT5Ngv7QFAgMHD44g-9uOo,433
3
+ webwidgets/compilation/css/__init__.py,sha256=A4VUGyy92Vn32Km2VuJL5bBmLdovN6PDfv_DctRdzJk,534
4
+ webwidgets/compilation/css/css.py,sha256=d7sxMOfT2s2sxG2O4LB4zSuknDb_fPcikKkiQk70vpc,9299
5
+ webwidgets/compilation/css/css_rule.py,sha256=QkeyVc9hmRwz0QY1avDugBpMM849VXcvJi18-c9h4x4,3530
6
+ webwidgets/compilation/css/sections/__init__.py,sha256=qPLq_w0kIPGvIIXxsblF7iUdm9AOA-CUl06krTMnHHI,465
7
+ webwidgets/compilation/css/sections/css_section.py,sha256=LfQojXSYbsh9UcbqD4vn6heEO7qjGNrwy7Py5bsvy9U,4020
8
+ webwidgets/compilation/css/sections/preamble.py,sha256=DwRwcKrVVeiypsHQn0FIQojpOQ3KUcrlaawxfipCue8,1251
9
+ webwidgets/compilation/css/sections/rule_section.py,sha256=BBeIJyVRz2ZeBxEC6VrEymXF6LjzE_GPu6X04A-lGF8,1456
10
+ webwidgets/compilation/html/__init__.py,sha256=h8eWh8BbjLZA1wIGAeOxyhZIUM1e36ZgMTwL5SQajHc,514
11
+ webwidgets/compilation/html/html_node.py,sha256=lOf1LEoVx23teWHfdlt5IpSv14cE6EI-aDyFGC_BXSU,11697
12
+ webwidgets/compilation/html/html_tags.py,sha256=R13axcPAOnqRVPM8FP0k04QIW7gZMSJt3jlsv7Q-fXg,2276
13
+ webwidgets/utility/__init__.py,sha256=Sl-dzpPPTHykkmLSfobhqHmlzUSPtvhaR4xtJy_tiOg,505
14
+ webwidgets/utility/indentation.py,sha256=BaOQRqWdG7T5k_g1-ia9jewPFZjD3afjZH_Fc4NSVwo,906
15
+ webwidgets/utility/representation.py,sha256=lQ15v_DZOHBQKLM8pzRE1tuJkU_modhPTpWpSJ2lBCE,1061
16
+ webwidgets/utility/sanitizing.py,sha256=OKJRDqk-OXYCWeK6ie3GdfQvb49wTs93kd971mg5oK0,5770
17
+ webwidgets/utility/validation.py,sha256=RB3JvMcVP6AKP1yrl44kFUQRzswtMa6ql7r2tqvrojE,6989
18
+ webwidgets/website/__init__.py,sha256=zp4N3CtY0SLNfDV9p2Y0tqbta-vFOX1PSJr7eQ9rQdk,471
19
+ webwidgets/website/compiled_website.py,sha256=lR_sabYtdWiRWicyxEFs4yxRUB_TbMowpsNz3CtqQBQ,1129
20
+ webwidgets/website/website.py,sha256=2vdIxYLXOu_otiF-KTOBigoic0aUWwgglfXAAZRzURg,3349
21
+ webwidgets/widgets/__init__.py,sha256=J2br7F-16URKvWshkJcc4nth27YQsaLrdVZu0xXx5CU,449
22
+ webwidgets/widgets/widget.py,sha256=NRyiy_vBStX2r-LBeJpqFy-AMw1aaqxrx4sz-GIdJnE,1034
23
+ webwidgets/widgets/containers/__init__.py,sha256=bKhkQHUfBMjYi7f0lPSBwP2EMn_M7n9VIh3He4gRqyc,484
24
+ webwidgets/widgets/containers/box.py,sha256=L5qYFsHWs0YzpAZ2Mv_i3pWzMp5od98tLjjf8KpnDhY,2351
25
+ webwidgets/widgets/containers/container.py,sha256=blpO2y9IiZ_4opwe9pqsSJPAZEN1P_ZRK4a27NpnMrg,1158
26
+ webwidgets/widgets/containers/page.py,sha256=sJ8QDmZ_6jzRFt4lyiAEPwjPvDTKm8EWOjz_Tq_cdjU,2180
27
+ webwidgets-1.1.0.dist-info/METADATA,sha256=b4hRl-cnHN3ndE_8RhOZPM_YFNtoJJfn6u8ovtFi8LA,1607
28
+ webwidgets-1.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
29
+ webwidgets-1.1.0.dist-info/licenses/LICENSE,sha256=LISw1mw5eK6i8adFSlx6zltZxrJFwurngVdZAEU8g_I,1064
30
+ webwidgets-1.1.0.dist-info/RECORD,,
@@ -1,24 +0,0 @@
1
- webwidgets/__init__.py,sha256=IZAAOuEwU_cC_oy0dJ2WSw9wXnwrLyZ4mqszBPma7MA,549
2
- webwidgets/compilation/__init__.py,sha256=hb61nhmPTghIzuA_hun98xT5Ngv7QFAgMHD44g-9uOo,433
3
- webwidgets/compilation/css/__init__.py,sha256=ZisiFaw9RqNuVqewOjnEOLLLZwgjfW95yruMXGRGp_I,484
4
- webwidgets/compilation/css/css.py,sha256=93WuxY-ADUDK_cBuGS-hUuV4HctZjtaG3cqeoE4S3oQ,9725
5
- webwidgets/compilation/html/__init__.py,sha256=iupXt6punHDLAFdygshmQeFVOLCeZ8HbwCWclL1FH54,521
6
- webwidgets/compilation/html/html_node.py,sha256=lOf1LEoVx23teWHfdlt5IpSv14cE6EI-aDyFGC_BXSU,11697
7
- webwidgets/compilation/html/html_tags.py,sha256=tFc5P6_rqyetFXJKlxt8n_IhBD758LHPql9peU9__6o,2188
8
- webwidgets/utility/__init__.py,sha256=Sl-dzpPPTHykkmLSfobhqHmlzUSPtvhaR4xtJy_tiOg,505
9
- webwidgets/utility/indentation.py,sha256=BaOQRqWdG7T5k_g1-ia9jewPFZjD3afjZH_Fc4NSVwo,906
10
- webwidgets/utility/representation.py,sha256=lQ15v_DZOHBQKLM8pzRE1tuJkU_modhPTpWpSJ2lBCE,1061
11
- webwidgets/utility/sanitizing.py,sha256=OKJRDqk-OXYCWeK6ie3GdfQvb49wTs93kd971mg5oK0,5770
12
- webwidgets/utility/validation.py,sha256=bUjpiGP59GW3DPvQ1hwR5ezBMmcSd6v4xlDLwTHZv_A,4261
13
- webwidgets/website/__init__.py,sha256=zp4N3CtY0SLNfDV9p2Y0tqbta-vFOX1PSJr7eQ9rQdk,471
14
- webwidgets/website/compiled_website.py,sha256=lR_sabYtdWiRWicyxEFs4yxRUB_TbMowpsNz3CtqQBQ,1129
15
- webwidgets/website/website.py,sha256=a5Qmm4DOIYMXHoBDyKzB6Ex2kaPFCSPgodF7LAVyrPE,3339
16
- webwidgets/widgets/__init__.py,sha256=J2br7F-16URKvWshkJcc4nth27YQsaLrdVZu0xXx5CU,449
17
- webwidgets/widgets/widget.py,sha256=8ZRcVmmtjQzeA_uGZi10H4XvqgGEtGmr9275FId8zt0,1039
18
- webwidgets/widgets/containers/__init__.py,sha256=6LPlYaxXiMgC5YPHhi0HLhn7iCeh_5IFY70mv7a-wSA,452
19
- webwidgets/widgets/containers/container.py,sha256=blpO2y9IiZ_4opwe9pqsSJPAZEN1P_ZRK4a27NpnMrg,1158
20
- webwidgets/widgets/containers/page.py,sha256=sJ8QDmZ_6jzRFt4lyiAEPwjPvDTKm8EWOjz_Tq_cdjU,2180
21
- webwidgets-1.0.0.dist-info/METADATA,sha256=DHfGCHdolxBicrkQjAzWGm_B7d_SgY2OnwZ261tSxUk,1434
22
- webwidgets-1.0.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
23
- webwidgets-1.0.0.dist-info/licenses/LICENSE,sha256=LISw1mw5eK6i8adFSlx6zltZxrJFwurngVdZAEU8g_I,1064
24
- webwidgets-1.0.0.dist-info/RECORD,,