webwidgets 0.1.0__py3-none-any.whl → 0.2.1__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 +1 -1
- webwidgets/compilation/__init__.py +1 -0
- webwidgets/compilation/css/__init__.py +14 -0
- webwidgets/compilation/css/css.py +229 -0
- webwidgets/compilation/html/__init__.py +2 -1
- webwidgets/compilation/html/html_node.py +71 -11
- webwidgets/compilation/html/html_tags.py +40 -0
- webwidgets/utility/__init__.py +2 -0
- webwidgets/utility/representation.py +34 -0
- webwidgets/utility/sanitizing.py +2 -2
- webwidgets/utility/validation.py +97 -0
- {webwidgets-0.1.0.dist-info → webwidgets-0.2.1.dist-info}/METADATA +1 -1
- webwidgets-0.2.1.dist-info/RECORD +15 -0
- webwidgets-0.1.0.dist-info/RECORD +0 -10
- {webwidgets-0.1.0.dist-info → webwidgets-0.2.1.dist-info}/WHEEL +0 -0
- {webwidgets-0.1.0.dist-info → webwidgets-0.2.1.dist-info}/licenses/LICENSE +0 -0
webwidgets/__init__.py
CHANGED
@@ -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 .css import compile_css, CSSRule, CompiledCSS, apply_css, \
|
14
|
+
default_rule_namer
|
@@ -0,0 +1,229 @@
|
|
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
|
+
import itertools
|
14
|
+
from typing import Callable, Dict, List, Union
|
15
|
+
from webwidgets.compilation.html.html_node import HTMLNode
|
16
|
+
from webwidgets.utility.representation import ReprMixin
|
17
|
+
from webwidgets.utility.validation import validate_css_identifier
|
18
|
+
|
19
|
+
|
20
|
+
class CSSRule(ReprMixin):
|
21
|
+
"""A rule in a style sheet.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self, name: str, declarations: Dict[str, str]):
|
25
|
+
"""Stores the name and declarations of the rule.
|
26
|
+
|
27
|
+
:param name: The name of the rule.
|
28
|
+
:type name: str
|
29
|
+
:param declarations: The CSS declarations for the rule, specified as a
|
30
|
+
dictionary where keys are property names and values are their
|
31
|
+
corresponding values. For example: `{'color': 'red'}`
|
32
|
+
:type declarations: Dict[str, str]
|
33
|
+
"""
|
34
|
+
super().__init__()
|
35
|
+
self.name = name
|
36
|
+
self.declarations = declarations
|
37
|
+
|
38
|
+
|
39
|
+
class CompiledCSS(ReprMixin):
|
40
|
+
"""A utility class to hold compiled CSS rules.
|
41
|
+
"""
|
42
|
+
|
43
|
+
def __init__(self, trees: List[HTMLNode], rules: List[CSSRule],
|
44
|
+
mapping: Dict[int, List[CSSRule]]):
|
45
|
+
"""Stores compiled CSS rules.
|
46
|
+
|
47
|
+
:param trees: The HTML trees at the origin of the compilation. These
|
48
|
+
are the elements that have been styled with CSS properties.
|
49
|
+
:type trees: List[HTMLNode]
|
50
|
+
:param rules: The compiled CSS rules.
|
51
|
+
:type rules: List[CSSRule]
|
52
|
+
:param mapping: A dictionary mapping each node ID to a list of rules
|
53
|
+
that achieve the same style.
|
54
|
+
:type mapping: Dict[int, List[CSSRule]]
|
55
|
+
"""
|
56
|
+
super().__init__()
|
57
|
+
self.trees = trees
|
58
|
+
self.rules = rules
|
59
|
+
self.mapping = mapping
|
60
|
+
|
61
|
+
def to_css(self, indent_size: int = 4) -> str:
|
62
|
+
"""Converts the `rules` dictionary of the :py:class:`CompiledCSS`
|
63
|
+
object into CSS code.
|
64
|
+
|
65
|
+
Rule names are converted to class selectors. Note that each rule and
|
66
|
+
property name is validated with :py:func:`validate_css_identifier`
|
67
|
+
before being converted.
|
68
|
+
|
69
|
+
:param indent_size: The number of spaces to use for indentation in the
|
70
|
+
CSS code. Defaults to 4.
|
71
|
+
:type indent_size: int
|
72
|
+
:return: The CSS code as a string.
|
73
|
+
:rtype: str
|
74
|
+
"""
|
75
|
+
# Initializing code and defining indentation
|
76
|
+
css_code = ""
|
77
|
+
indentation = ' ' * indent_size
|
78
|
+
|
79
|
+
# Writing down each rule
|
80
|
+
for i, rule in enumerate(self.rules):
|
81
|
+
validate_css_identifier(rule.name)
|
82
|
+
css_code += f".{rule.name}" + " {\n"
|
83
|
+
for property_name, value in rule.declarations.items():
|
84
|
+
validate_css_identifier(property_name)
|
85
|
+
css_code += f"{indentation}{property_name}: {value};\n"
|
86
|
+
css_code += "}" + ('\n\n' if i < len(self.rules) - 1 else '')
|
87
|
+
|
88
|
+
return css_code
|
89
|
+
|
90
|
+
|
91
|
+
def compile_css(trees: Union[HTMLNode, List[HTMLNode]],
|
92
|
+
rule_namer: Callable[[List[CSSRule], int],
|
93
|
+
str] = None) -> CompiledCSS:
|
94
|
+
"""Computes optimized CSS rules from the given HTML trees.
|
95
|
+
|
96
|
+
The main purpose of this function is to reduce the number of CSS rules
|
97
|
+
required to achieve a particular style across one or more HTML trees. The
|
98
|
+
function takes a list of HTML nodes as input (not necessarily from the same
|
99
|
+
tree) and computes an optimized set of CSS rules that achieves the same
|
100
|
+
style across all nodes. The resulting :py:class:`CompiledCSS` object
|
101
|
+
contains the optimized rules and their mapping to each node.
|
102
|
+
|
103
|
+
For example, the following tree:
|
104
|
+
|
105
|
+
.. code-block:: python
|
106
|
+
|
107
|
+
tree = HTMLNode(
|
108
|
+
style={"margin": "0", "padding": "0"},
|
109
|
+
children=[
|
110
|
+
HTMLNode(style={"margin": "0", "padding": "0"}),
|
111
|
+
HTMLNode(style={"margin": "0", "color": "blue"}),
|
112
|
+
]
|
113
|
+
)
|
114
|
+
|
115
|
+
can be stylistically described with only 3 CSS rules:
|
116
|
+
|
117
|
+
.. code-block:: python
|
118
|
+
|
119
|
+
>>> compiled_css = compile_css(tree)
|
120
|
+
>>> print(compiled_css.rules)
|
121
|
+
[
|
122
|
+
CSSRule(name='r0', declarations={'color': 'blue'}),
|
123
|
+
CSSRule(name='r1', declarations={'margin': '0'}),
|
124
|
+
CSSRule(name='r2', declarations={'padding': '0'})
|
125
|
+
]
|
126
|
+
|
127
|
+
:param trees: A single tree or a list of trees to optimize over. All
|
128
|
+
children are recursively included in the compilation.
|
129
|
+
:type trees: Union[HTMLNode, List[HTMLNode]]
|
130
|
+
:param rule_namer: A callable that takes two arguments, which are the list
|
131
|
+
of all compiled rules and an index within that list, and returns a
|
132
|
+
unique name for the rule at the given index.
|
133
|
+
|
134
|
+
This argument allows to customize the rule naming process and use names
|
135
|
+
other than the default `"r0"`, `"r1"`, etc. For example, it can be used
|
136
|
+
to achieve something similar to Tailwind CSS and name rules according
|
137
|
+
to what they achieve, e.g. by prefixing their name with `"m"` for
|
138
|
+
margin rules or `"p"` for padding rules. Note that all rule names will
|
139
|
+
be validated with the :py:func:`validate_css_identifier` function
|
140
|
+
before being written into CSS code.
|
141
|
+
|
142
|
+
Defaults to the :py:func:`default_rule_namer` function which implements
|
143
|
+
a default naming strategy where each rule is named `"r{i}"` where `i`
|
144
|
+
is the index of the rule in the list.
|
145
|
+
:type rule_namer: Callable[[List[CSSRule], int], str]
|
146
|
+
:return: The :py:class:`CompiledCSS` object containing the optimized rules.
|
147
|
+
Every HTML node present in one or more of the input trees is included
|
148
|
+
in the :py:attr:`CompiledCSS.mapping` attribute, even if the node does
|
149
|
+
not have a style. Rules are alphabetically ordered by name in the
|
150
|
+
mapping.
|
151
|
+
:rtype: CompiledCSS
|
152
|
+
"""
|
153
|
+
# Handling case of a single tree
|
154
|
+
if isinstance(trees, HTMLNode):
|
155
|
+
trees = [trees]
|
156
|
+
|
157
|
+
# Handling default rule_namer
|
158
|
+
rule_namer = default_rule_namer if rule_namer is None else rule_namer
|
159
|
+
|
160
|
+
# For now, we just return a simple mapping where each CSS property defines
|
161
|
+
# its own ruleset
|
162
|
+
styles = {k: v for tree in trees for k, v in tree.get_styles().items()}
|
163
|
+
properties = set(itertools.chain.from_iterable(s.items()
|
164
|
+
for s in styles.values()))
|
165
|
+
rules = [CSSRule(None, dict([p])) # Initializing with no name
|
166
|
+
for p in sorted(properties)]
|
167
|
+
for i, rule in enumerate(rules): # Assigning name from callback
|
168
|
+
rule.name = rule_namer(rules, i)
|
169
|
+
rules = sorted(rules, key=lambda r: r.name) # Sorting by name
|
170
|
+
mapping = {node_id: [r for r in rules if
|
171
|
+
set(r.declarations.items()).issubset(style.items())]
|
172
|
+
for node_id, style in styles.items()}
|
173
|
+
return CompiledCSS(trees, rules, mapping)
|
174
|
+
|
175
|
+
|
176
|
+
def apply_css(css: CompiledCSS, tree: HTMLNode) -> None:
|
177
|
+
"""Applies the CSS rules to the given tree.
|
178
|
+
|
179
|
+
Rules are added as HTML classes to each node with a style in the tree. If a
|
180
|
+
node does not have a `class` attribute yet, it will be created for that
|
181
|
+
node. Nodes that do not have any style are left untouched.
|
182
|
+
|
183
|
+
Note that this function is recursive and calls itself on each child node of
|
184
|
+
the tree.
|
185
|
+
|
186
|
+
:param css: The compiled CSS object containing the rules to apply and the
|
187
|
+
mapping to each node. It should have been created by invoking
|
188
|
+
:py:func:`compile_css` on the given tree, but it can be modified before
|
189
|
+
passing it to this function, as long as its content remains consistent.
|
190
|
+
:type css: CompiledCSS
|
191
|
+
:param tree: The tree to which the CSS rules should be applied. It will be
|
192
|
+
modified in place by this function. If you want to keep the original
|
193
|
+
tree unchanged, make a deep copy of it using its
|
194
|
+
:py:meth:`HTMLNode.copy` method and pass this copy instead.
|
195
|
+
:type tree: HTMLNode
|
196
|
+
"""
|
197
|
+
# Only modifying nodes if they have a style (and therefore if the list of
|
198
|
+
# rules mapped to them in `css.mapping` is not empty)
|
199
|
+
if tree.style:
|
200
|
+
|
201
|
+
# Listing rules to add as classes. We do not add rules that are already
|
202
|
+
# there.
|
203
|
+
rules_to_add = [r.name for r in css.mapping[id(tree)] if r.name not in
|
204
|
+
tree.attributes.get('class', '').split(' ')]
|
205
|
+
|
206
|
+
# Updating the class attribute. If it already exists and is not empty,
|
207
|
+
# we need to insert a space before adding the CSS classes.
|
208
|
+
maybe_space = ' ' if tree.attributes.get(
|
209
|
+
'class', None) and rules_to_add else ''
|
210
|
+
tree.attributes['class'] = tree.attributes.get(
|
211
|
+
'class', '') + maybe_space + ' '.join(rules_to_add)
|
212
|
+
|
213
|
+
# Recursively applying the CSS rules to all child nodes of the tree
|
214
|
+
for child in tree.children:
|
215
|
+
apply_css(css, child)
|
216
|
+
|
217
|
+
|
218
|
+
def default_rule_namer(rules: List[CSSRule], index: int) -> str:
|
219
|
+
"""Default rule naming function. Returns a string like "r{i}" where {i} is
|
220
|
+
the index of the rule.
|
221
|
+
|
222
|
+
:param rules: List of all compiled CSSRule objects. This argument is not
|
223
|
+
used in this function, but it can be used in other naming strategies.
|
224
|
+
:type rules: List[CSSRule]
|
225
|
+
:param index: Index of the rule being named.
|
226
|
+
:type index: int
|
227
|
+
:return: A string like `"r{i}"` where `i` is the index of the rule.
|
228
|
+
"""
|
229
|
+
return f'r{index}'
|
@@ -10,4 +10,5 @@
|
|
10
10
|
#
|
11
11
|
# =======================================================================
|
12
12
|
|
13
|
-
from .html_node import HTMLNode, no_start_tag, no_end_tag, RawText
|
13
|
+
from .html_node import HTMLNode, no_start_tag, no_end_tag, one_line, RawText
|
14
|
+
from .html_tags import TextNode
|
@@ -10,25 +10,35 @@
|
|
10
10
|
#
|
11
11
|
# =======================================================================
|
12
12
|
|
13
|
+
import copy
|
13
14
|
import itertools
|
14
15
|
from typing import Any, Dict, List, Union
|
16
|
+
from webwidgets.utility.representation import ReprMixin
|
15
17
|
from webwidgets.utility.sanitizing import sanitize_html_text
|
18
|
+
from webwidgets.utility.validation import validate_html_class
|
16
19
|
|
17
20
|
|
18
|
-
class HTMLNode:
|
21
|
+
class HTMLNode(ReprMixin):
|
19
22
|
"""Represents an HTML node (for example, a div or a span).
|
20
23
|
"""
|
21
24
|
|
22
25
|
one_line: bool = False
|
23
26
|
|
24
|
-
def __init__(self, children: List['HTMLNode'] =
|
25
|
-
|
27
|
+
def __init__(self, children: List['HTMLNode'] = None,
|
28
|
+
attributes: Dict[str, str] = None, style: Dict[str, str] = None):
|
29
|
+
"""Creates an HTMLNode with optional children, attributes, and style.
|
26
30
|
|
27
31
|
:param children: List of child HTML nodes. Defaults to an empty list.
|
32
|
+
:type children: List[HTMLNode]
|
28
33
|
:param attributes: Dictionary of attributes for the node. Defaults to an empty dictionary.
|
34
|
+
:type attributes: Dict[str, str]
|
35
|
+
:param style: Dictionary of CSS properties for the node. Defaults to an empty dictionary.
|
36
|
+
:type style: Dict[str, str]
|
29
37
|
"""
|
30
|
-
|
31
|
-
self.
|
38
|
+
super().__init__()
|
39
|
+
self.children = [] if children is None else children
|
40
|
+
self.attributes = {} if attributes is None else attributes
|
41
|
+
self.style = {} if style is None else style
|
32
42
|
|
33
43
|
def _get_tag_name(self) -> str:
|
34
44
|
"""Returns the tag name of the HTML node.
|
@@ -51,21 +61,55 @@ class HTMLNode:
|
|
51
61
|
)
|
52
62
|
|
53
63
|
def add(self, child: 'HTMLNode') -> None:
|
54
|
-
"""
|
55
|
-
Adds a child to the HTML node.
|
64
|
+
"""Adds a child to the HTML node.
|
56
65
|
|
57
66
|
:param child: The child to be added.
|
58
67
|
"""
|
59
68
|
self.children.append(child)
|
60
69
|
|
70
|
+
def copy(self, deep: bool = False) -> 'HTMLNode':
|
71
|
+
"""Returns a copy of the HTML node.
|
72
|
+
|
73
|
+
This method is just a convenient wrapper around Python's
|
74
|
+
`copy.copy()` and `copy.deepcopy()` methods.
|
75
|
+
|
76
|
+
:param deep: If True, creates a deep copy of the node and its children,
|
77
|
+
recursively. Otherwise, creates a shallow copy. Defaults to False.
|
78
|
+
:type deep: bool
|
79
|
+
:return: A new HTMLNode object that is a copy of the original.
|
80
|
+
:rtype: HTMLNode
|
81
|
+
"""
|
82
|
+
if deep:
|
83
|
+
return copy.deepcopy(self)
|
84
|
+
return copy.copy(self)
|
85
|
+
|
86
|
+
def get_styles(self) -> Dict[int, Dict[str, str]]:
|
87
|
+
"""Returns a dictionary mapping the node and all its children,
|
88
|
+
recursively, to their style.
|
89
|
+
|
90
|
+
Nodes are identified by their ID as obtained from Python's built-in
|
91
|
+
`id()` function.
|
92
|
+
|
93
|
+
:return: A dictionary mapping node IDs to styles.
|
94
|
+
:rtype: Dict[int, Dict[str, str]]
|
95
|
+
"""
|
96
|
+
styles = {id(self): self.style}
|
97
|
+
for child in self.children:
|
98
|
+
styles.update(child.get_styles())
|
99
|
+
return styles
|
100
|
+
|
61
101
|
@property
|
62
102
|
def start_tag(self) -> str:
|
63
103
|
"""Returns the opening tag of the HTML node, including any attributes.
|
64
104
|
|
105
|
+
Attributes are validated with :py:meth:`HTMLNode.validate_attributes`
|
106
|
+
before rendering.
|
107
|
+
|
65
108
|
:return: A string containing the opening tag of the element with its attributes.
|
66
109
|
:rtype: str
|
67
110
|
"""
|
68
111
|
# Rendering attributes
|
112
|
+
self.validate_attributes()
|
69
113
|
attributes = self._render_attributes()
|
70
114
|
maybe_space = ' ' if attributes else ''
|
71
115
|
|
@@ -87,8 +131,8 @@ class HTMLNode:
|
|
87
131
|
**kwargs: Any) -> Union[str, List[str]]:
|
88
132
|
"""Converts the HTML node into HTML code.
|
89
133
|
|
90
|
-
:param collapse_empty: If True, collapses
|
91
|
-
Defaults to True.
|
134
|
+
:param collapse_empty: If True, collapses elements without any children
|
135
|
+
into a single line. Defaults to True.
|
92
136
|
:type collapse_empty: bool
|
93
137
|
:param indent_size: The number of spaces to use for each indentation level.
|
94
138
|
:type indent_size: int
|
@@ -141,6 +185,13 @@ class HTMLNode:
|
|
141
185
|
# Otherwise, return a single string
|
142
186
|
return '\n'.join(html_lines)
|
143
187
|
|
188
|
+
def validate_attributes(self) -> None:
|
189
|
+
"""Validate the node's attributes and raises an exception with a
|
190
|
+
descriptive error message if any attribute is invalid.
|
191
|
+
"""
|
192
|
+
if "class" in self.attributes:
|
193
|
+
validate_html_class(self.attributes["class"])
|
194
|
+
|
144
195
|
|
145
196
|
def no_start_tag(cls):
|
146
197
|
"""Decorator to remove the start tag from an HTMLNode subclass.
|
@@ -164,13 +215,22 @@ def no_end_tag(cls):
|
|
164
215
|
return cls
|
165
216
|
|
166
217
|
|
218
|
+
def one_line(cls):
|
219
|
+
"""Decorator to make an HTMLNode subclass a one-line element.
|
220
|
+
|
221
|
+
:param cls: A subclass of HTMLNode.
|
222
|
+
:return: The given class with the `one_line` attribute set to True.
|
223
|
+
"""
|
224
|
+
cls.one_line = True
|
225
|
+
return cls
|
226
|
+
|
227
|
+
|
167
228
|
@no_start_tag
|
168
229
|
@no_end_tag
|
230
|
+
@one_line
|
169
231
|
class RawText(HTMLNode):
|
170
232
|
"""A raw text node that contains text without any HTML tags."""
|
171
233
|
|
172
|
-
one_line = True
|
173
|
-
|
174
234
|
def __init__(self, text: str):
|
175
235
|
"""Creates a raw text node.
|
176
236
|
|
@@ -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 .html_node import HTMLNode, one_line, RawText
|
14
|
+
from typing import Dict
|
15
|
+
|
16
|
+
|
17
|
+
@one_line
|
18
|
+
class TextNode(HTMLNode):
|
19
|
+
"""A one-line HTML element that only contains raw text (like `<h1>`).
|
20
|
+
|
21
|
+
A text node renders on one line and only contains one child: a
|
22
|
+
:py:class:`RawText` node with the text to be rendered.
|
23
|
+
"""
|
24
|
+
|
25
|
+
def __init__(self, text: str, attributes: Dict[str, str] = None,
|
26
|
+
style: Dict[str, str] = None):
|
27
|
+
"""Creates a new text node with the given text and attributes.
|
28
|
+
|
29
|
+
:param text: The text content of the node.
|
30
|
+
:type text: str
|
31
|
+
:param attributes: See :py:meth:`HTMLNode.__init__`. Defaults to an
|
32
|
+
empty dictionary.
|
33
|
+
:type attributes: Dict[str, str]
|
34
|
+
:param style: See :py:meth:`HTMLNode.__init__`. Defaults to an empty
|
35
|
+
dictionary.
|
36
|
+
:type style: Dict[str, str]
|
37
|
+
"""
|
38
|
+
super().__init__(children=[
|
39
|
+
RawText(text)
|
40
|
+
], attributes=attributes, style=style)
|
webwidgets/utility/__init__.py
CHANGED
@@ -0,0 +1,34 @@
|
|
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
|
+
class ReprMixin:
|
14
|
+
"""A mixin class that is represented with its variables when printed.
|
15
|
+
|
16
|
+
For example:
|
17
|
+
|
18
|
+
>>> class MyClass(RepresentedWithVars):
|
19
|
+
... def __init__(self, a, b):
|
20
|
+
... self.a = a
|
21
|
+
... self.b = b
|
22
|
+
>>> obj = MyClass(1, 2)
|
23
|
+
>>> print(obj)
|
24
|
+
MyClass(a=1, b=2)
|
25
|
+
"""
|
26
|
+
|
27
|
+
def __repr__(self) -> str:
|
28
|
+
"""Returns a string exposing all member variables of the class.
|
29
|
+
|
30
|
+
:return: A string representing the class with its variables.
|
31
|
+
:rtype: str
|
32
|
+
"""
|
33
|
+
variables = ', '.join(f'{k}={repr(v)}' for k, v in vars(self).items())
|
34
|
+
return f"{self.__class__.__name__}({variables})"
|
webwidgets/utility/sanitizing.py
CHANGED
@@ -30,7 +30,7 @@ CHAR_TO_HTML_ENTITIES = {k: tuple(v)
|
|
30
30
|
for k, v in CHAR_TO_HTML_ENTITIES.items()}
|
31
31
|
|
32
32
|
|
33
|
-
# Regular expression
|
33
|
+
# Regular expression matching all isolated '&' characters that are not part of an
|
34
34
|
# HTML entity.
|
35
35
|
_REGEX_AMP = re.compile(f"&(?!({'|'.join(HTML_ENTITIES.keys())}))")
|
36
36
|
|
@@ -107,7 +107,7 @@ def sanitize_html_text(text: str, replace_all_entities: bool = False) -> str:
|
|
107
107
|
:type text: str
|
108
108
|
:param replace_all_entities: Whether to replace every character that can be
|
109
109
|
represented by an HTML entity. Use False to skip non-mandatory characters
|
110
|
-
and increase speed.
|
110
|
+
and increase speed. Defaults to False.
|
111
111
|
:type replace_all_entities: bool
|
112
112
|
:return: The sanitized HTML text.
|
113
113
|
:rtype: str
|
@@ -0,0 +1,97 @@
|
|
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 *
|
14
|
+
import re
|
15
|
+
|
16
|
+
|
17
|
+
def validate_css_identifier(identifier: str) -> None:
|
18
|
+
"""Checks if the given identifier is a valid identifier token according to
|
19
|
+
the CSS syntax rules and raises an exception if not.
|
20
|
+
|
21
|
+
An identifier token is a sequence of characters that can be used as part of
|
22
|
+
a CSS rule, like a class name or an ID. The concept essentially corresponds
|
23
|
+
to that of an `ident-token` in the official CSS specification.
|
24
|
+
|
25
|
+
This function enforces the following rules:
|
26
|
+
- the identifier must only contain letters (`a-z`, `A-Z`), digits (`0-9`),
|
27
|
+
underscores (`_`), and hyphens (`-`)
|
28
|
+
- the identifier must start with either a letter, an underscore, or a
|
29
|
+
double hyphen (`--`)
|
30
|
+
|
31
|
+
Note that this function imposes stricter rules on identifier tokens than
|
32
|
+
the official CSS specification - more precisely, than chapter 4 of the CSS
|
33
|
+
Syntax Module Level 3 (see source:
|
34
|
+
https://www.w3.org/TR/css-syntax-3/#tokenization - note that this chapter
|
35
|
+
remains the same in the current draft for Level 4). For example, this
|
36
|
+
function does not allow escaped special characters nor identifier tokens
|
37
|
+
starting with a single hyphen whereas the specification does.
|
38
|
+
|
39
|
+
:param identifier: The string to be validated as an identifier token.
|
40
|
+
:type identifier: str
|
41
|
+
:raises ValueError: If the identifier is not a valid identifier token and
|
42
|
+
does not respect the specified rules.
|
43
|
+
"""
|
44
|
+
# Check if identifier starts with anything else than a letter, an
|
45
|
+
# underscore, or a double hyphen
|
46
|
+
if not re.match(r'^[a-zA-Z_]+|--', identifier):
|
47
|
+
raise ValueError("CSS identifier must start with either a letter, an "
|
48
|
+
"underscore, or a double hyphen (`--`), but got: "
|
49
|
+
f"'{identifier}'")
|
50
|
+
|
51
|
+
# Check if identifier contains invalid characters
|
52
|
+
if not re.match(r'^[a-zA-Z0-9_-]+$', identifier):
|
53
|
+
invalid_chars = re.findall('[^a-zA-Z0-9_-]', identifier)
|
54
|
+
raise ValueError("Invalid character(s) in CSS idenfitier "
|
55
|
+
f"'{identifier}': {', '.join(invalid_chars)}\n"
|
56
|
+
"Only letters, digits, hyphens, and underscores are "
|
57
|
+
"allowed.")
|
58
|
+
|
59
|
+
|
60
|
+
def validate_html_class(class_attribute: str) -> None:
|
61
|
+
"""Checks if the given HTML class attribute is valid and raises an
|
62
|
+
exception if not.
|
63
|
+
|
64
|
+
This function enforces the following rules:
|
65
|
+
- the class attribute cannot start nor end with a space
|
66
|
+
- the class attribute cannot contain double spaces
|
67
|
+
- each class in the attribute must be a valid CSS identifier, as validated
|
68
|
+
by the :py:func:`validate_css_identifier` function.
|
69
|
+
|
70
|
+
Note that this function imposes stricter rules than rule 2.3.7 of the HTML5
|
71
|
+
specification (see source:
|
72
|
+
https://html.spec.whatwg.org/#set-of-space-separated-tokens). For example,
|
73
|
+
it does not allow for leading nor trailing spaces whereas the specification
|
74
|
+
does.
|
75
|
+
|
76
|
+
:param class_attribute: The HTML class attribute to be validated.
|
77
|
+
:type class_attribute: str
|
78
|
+
:raises ValueError: If the class attribute is invalid and does not respect
|
79
|
+
the specified rules.
|
80
|
+
"""
|
81
|
+
# Allow for empty attribute
|
82
|
+
if not class_attribute:
|
83
|
+
return
|
84
|
+
|
85
|
+
# Check if the class attribute starts or ends with a space
|
86
|
+
if class_attribute.startswith(' ') or class_attribute.endswith(' '):
|
87
|
+
raise ValueError("Class attribute cannot start nor end with a space, "
|
88
|
+
f"but got: '{class_attribute}'")
|
89
|
+
|
90
|
+
# Check for double spaces in the class attribute
|
91
|
+
if ' ' in class_attribute:
|
92
|
+
raise ValueError("Class attribute cannot contain double spaces, "
|
93
|
+
f"but got: '{class_attribute}'")
|
94
|
+
|
95
|
+
# Check each class individually
|
96
|
+
for c in class_attribute.split(' '):
|
97
|
+
validate_css_identifier(c)
|
@@ -0,0 +1,15 @@
|
|
1
|
+
webwidgets/__init__.py,sha256=lWweaTCcPhSUa2zi5l7UnoDSgup_q81dh0pn_kGYYT0,481
|
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=msFTyuIjEvLTKk4H-tvvr9HK1K6N-_hcF1pmOJWT1S8,9654
|
5
|
+
webwidgets/compilation/html/__init__.py,sha256=NRccbCUKdhmK51MnYFO8q1sS8fWhAggRnMiGDG7lQMQ,505
|
6
|
+
webwidgets/compilation/html/html_node.py,sha256=QptDwSD2ljzR5S-r5rXuRBeOz8EF55OlZlWTXgQzI4Y,10189
|
7
|
+
webwidgets/compilation/html/html_tags.py,sha256=U2HmhLkV6BOqTmgvIMlmMCQysQ7i2nEAVWJzZe74ucA,1388
|
8
|
+
webwidgets/utility/__init__.py,sha256=nY-j5tu45qGl09lRyMyxkT4zsUf2jCfeXWcx954yIvM,478
|
9
|
+
webwidgets/utility/representation.py,sha256=lQ15v_DZOHBQKLM8pzRE1tuJkU_modhPTpWpSJ2lBCE,1061
|
10
|
+
webwidgets/utility/sanitizing.py,sha256=OKJRDqk-OXYCWeK6ie3GdfQvb49wTs93kd971mg5oK0,5770
|
11
|
+
webwidgets/utility/validation.py,sha256=bUjpiGP59GW3DPvQ1hwR5ezBMmcSd6v4xlDLwTHZv_A,4261
|
12
|
+
webwidgets-0.2.1.dist-info/METADATA,sha256=Adl61Eg47Lw7AuZ5sJN_9-MMumjgsEVFZ33glOceGUk,550
|
13
|
+
webwidgets-0.2.1.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
14
|
+
webwidgets-0.2.1.dist-info/licenses/LICENSE,sha256=LISw1mw5eK6i8adFSlx6zltZxrJFwurngVdZAEU8g_I,1064
|
15
|
+
webwidgets-0.2.1.dist-info/RECORD,,
|
@@ -1,10 +0,0 @@
|
|
1
|
-
webwidgets/__init__.py,sha256=V5LcquUtoHyCfL072Oo3HNH73msF9lKd4Vx5odghyZk,481
|
2
|
-
webwidgets/compilation/__init__.py,sha256=rebNuvAfuSVcRiuMOezktEod6C7qKl9tp__ddV4PNMg,415
|
3
|
-
webwidgets/compilation/html/__init__.py,sha256=t7xIAup9slzxfyp-spbXFM8YxPBJF0JJyaAmMe5NmXI,463
|
4
|
-
webwidgets/compilation/html/html_node.py,sha256=R4SsjvmbiM4WvcVuwhyXLwxYqdk9MoqVnr4idqFpZ0s,7846
|
5
|
-
webwidgets/utility/__init__.py,sha256=6lYOxZIb_wcNGpu55FjPN9fTben37x8I2XU6HOPmADo,422
|
6
|
-
webwidgets/utility/sanitizing.py,sha256=L72-E6er9p5e3nLupJJqcMMB62O_4lYzaDyMEyhBjE4,5768
|
7
|
-
webwidgets-0.1.0.dist-info/METADATA,sha256=NgmpZVNES4wgiMZSIWqE1CKDtPy3YRrnlIe7DV0dcH0,550
|
8
|
-
webwidgets-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
9
|
-
webwidgets-0.1.0.dist-info/licenses/LICENSE,sha256=LISw1mw5eK6i8adFSlx6zltZxrJFwurngVdZAEU8g_I,1064
|
10
|
-
webwidgets-0.1.0.dist-info/RECORD,,
|
File without changes
|
File without changes
|