jx 0.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.
- jx/__init__.py +5 -0
- jx/attrs.py +352 -0
- jx/catalog.py +227 -0
- jx/component.py +181 -0
- jx/exceptions.py +57 -0
- jx/meta.py +166 -0
- jx/parser.py +215 -0
- jx/utils.py +13 -0
- jx-0.1.0.dist-info/METADATA +42 -0
- jx-0.1.0.dist-info/RECORD +13 -0
- jx-0.1.0.dist-info/WHEEL +5 -0
- jx-0.1.0.dist-info/licenses/LICENSE +21 -0
- jx-0.1.0.dist-info/top_level.txt +1 -0
jx/__init__.py
ADDED
jx/attrs.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import typing as t
|
|
6
|
+
from collections import UserString
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
|
|
9
|
+
from markupsafe import Markup
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CLASS_KEY = "class"
|
|
13
|
+
CLASS_ALT_KEY = "classes"
|
|
14
|
+
CLASS_KEYS = (CLASS_KEY, CLASS_ALT_KEY)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def split(ssl: str) -> list[str]:
|
|
18
|
+
return re.split(r"\s+", ssl.strip())
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def quote(text: str) -> str:
|
|
22
|
+
if '"' in text:
|
|
23
|
+
if "'" in text:
|
|
24
|
+
text = text.replace('"', """)
|
|
25
|
+
return f'"{text}"'
|
|
26
|
+
else:
|
|
27
|
+
return f"'{text}'"
|
|
28
|
+
|
|
29
|
+
return f'"{text}"'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class LazyString(UserString):
|
|
33
|
+
"""
|
|
34
|
+
Behave like regular strings, but the actual casting of the initial value
|
|
35
|
+
is deferred until the value is actually required.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
__slots__ = ("_seq",)
|
|
39
|
+
|
|
40
|
+
def __init__(self, seq):
|
|
41
|
+
self._seq = seq
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def data(self): # type: ignore
|
|
45
|
+
return str(self._seq)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Attrs:
|
|
49
|
+
"""
|
|
50
|
+
Contains all the HTML attributes/properties (a property is an
|
|
51
|
+
attribute without a value) passed to a component but that weren't
|
|
52
|
+
in the declared attributes list.
|
|
53
|
+
|
|
54
|
+
For HTML classes you can use the name "classes" (instead of "class")
|
|
55
|
+
if you need to.
|
|
56
|
+
|
|
57
|
+
**NOTE**: The string values passed to this class, are not cast to `str` until
|
|
58
|
+
the string representation is actually needed, for example when
|
|
59
|
+
`attrs.render()` is invoked.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
|
|
64
|
+
attributes: "dict[str, str | LazyString]" = {}
|
|
65
|
+
properties: set[str] = set()
|
|
66
|
+
|
|
67
|
+
class_names = split(" ".join([
|
|
68
|
+
str(attrs.pop(CLASS_KEY, "")),
|
|
69
|
+
str(attrs.get(CLASS_ALT_KEY, "")),
|
|
70
|
+
]))
|
|
71
|
+
self.__classes = {name for name in class_names if name}
|
|
72
|
+
|
|
73
|
+
for name, value in attrs.items():
|
|
74
|
+
if name.startswith("_"):
|
|
75
|
+
continue
|
|
76
|
+
name = name.replace("_", "-")
|
|
77
|
+
if value is True:
|
|
78
|
+
properties.add(name)
|
|
79
|
+
elif value is not False and value is not None:
|
|
80
|
+
attributes[name] = LazyString(value)
|
|
81
|
+
|
|
82
|
+
self.__attributes = attributes
|
|
83
|
+
self.__properties = properties
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def classes(self) -> str:
|
|
87
|
+
"""
|
|
88
|
+
All the HTML classes alphabetically sorted and separated by a space.
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
attrs = HTMLAttrs({"class": "italic bold bg-blue wide abcde"})
|
|
94
|
+
attrs.set(class="bold text-white")
|
|
95
|
+
print(attrs.classes)
|
|
96
|
+
abcde bg-blue bold italic text-white wide
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
"""
|
|
100
|
+
return " ".join(sorted((self.__classes)))
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def as_dict(self) -> dict[str, t.Any]:
|
|
104
|
+
"""
|
|
105
|
+
An ordered dict of all the attributes and properties, both
|
|
106
|
+
sorted by name before join.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
attrs = HTMLAttrs({
|
|
112
|
+
"class": "lorem ipsum",
|
|
113
|
+
"data_test": True,
|
|
114
|
+
"hidden": True,
|
|
115
|
+
"aria_label": "hello",
|
|
116
|
+
"id": "world",
|
|
117
|
+
})
|
|
118
|
+
attrs.as_dict
|
|
119
|
+
{
|
|
120
|
+
"aria_label": "hello",
|
|
121
|
+
"class": "ipsum lorem",
|
|
122
|
+
"id": "world",
|
|
123
|
+
"data_test": True,
|
|
124
|
+
"hidden": True
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
attributes = self.__attributes.copy()
|
|
130
|
+
classes = self.classes
|
|
131
|
+
if classes:
|
|
132
|
+
attributes[CLASS_KEY] = classes
|
|
133
|
+
|
|
134
|
+
out: dict[str, t.Any] = dict(sorted(attributes.items()))
|
|
135
|
+
for name in sorted((self.__properties)):
|
|
136
|
+
out[name] = True
|
|
137
|
+
return out
|
|
138
|
+
|
|
139
|
+
def __getitem__(self, name: str) -> t.Any:
|
|
140
|
+
return self.get(name)
|
|
141
|
+
|
|
142
|
+
def __delitem__(self, name: str) -> None:
|
|
143
|
+
self._remove(name)
|
|
144
|
+
|
|
145
|
+
def __str__(self) -> str:
|
|
146
|
+
return str(self.as_dict)
|
|
147
|
+
|
|
148
|
+
def set(self, **kw) -> None:
|
|
149
|
+
"""
|
|
150
|
+
Sets an attribute or property
|
|
151
|
+
|
|
152
|
+
- Pass a name and a value to set an attribute (e.g. `type="text"`)
|
|
153
|
+
- Use `True` as a value to set a property (e.g. `disabled`)
|
|
154
|
+
- Use `False` to remove an attribute or property
|
|
155
|
+
- If the attribute is "class", the new classes are appended to
|
|
156
|
+
the old ones (if not repeated) instead of replacing them.
|
|
157
|
+
- The underscores in the names will be translated automatically to dashes,
|
|
158
|
+
so `aria_selected` becomes the attribute `aria-selected`.
|
|
159
|
+
|
|
160
|
+
Example:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
attrs = HTMLAttrs({"secret": "qwertyuiop"})
|
|
164
|
+
attrs.set(secret=False)
|
|
165
|
+
attrs.as_dict
|
|
166
|
+
{}
|
|
167
|
+
|
|
168
|
+
attrs.set(unknown=False, lorem="ipsum", count=42, data_good=True)
|
|
169
|
+
attrs.as_dict
|
|
170
|
+
{"count":42, "lorem":"ipsum", "data_good": True}
|
|
171
|
+
|
|
172
|
+
attrs = HTMLAttrs({"class": "b c a"})
|
|
173
|
+
attrs.set(class="c b f d e")
|
|
174
|
+
attrs.as_dict
|
|
175
|
+
{"class": "a b c d e f"}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
"""
|
|
179
|
+
for name, value in kw.items():
|
|
180
|
+
name = name.replace("_", "-")
|
|
181
|
+
if value is False or value is None:
|
|
182
|
+
self._remove(name)
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
if name in CLASS_KEYS:
|
|
186
|
+
self.add_class(value)
|
|
187
|
+
elif value is True:
|
|
188
|
+
self.__properties.add(name)
|
|
189
|
+
else:
|
|
190
|
+
self.__attributes[name] = value
|
|
191
|
+
|
|
192
|
+
def setdefault(self, **kw) -> None:
|
|
193
|
+
"""
|
|
194
|
+
Adds an attribute, but only if it's not already present.
|
|
195
|
+
|
|
196
|
+
The underscores in the names will be translated automatically to dashes,
|
|
197
|
+
so `aria_selected` becomes the attribute `aria-selected`.
|
|
198
|
+
|
|
199
|
+
Example:
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
attrs = HTMLAttrs({"lorem": "ipsum"})
|
|
203
|
+
attrs.setdefault(tabindex=0, lorem="meh")
|
|
204
|
+
attrs.as_dict
|
|
205
|
+
# "tabindex" changed but "lorem" didn't
|
|
206
|
+
{"lorem": "ipsum", tabindex: 0}
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
"""
|
|
210
|
+
for name, value in kw.items():
|
|
211
|
+
if value in (True, False, None):
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
if name in CLASS_KEYS:
|
|
215
|
+
self.add_class(value)
|
|
216
|
+
|
|
217
|
+
name = name.replace("_", "-")
|
|
218
|
+
if name not in self.__attributes:
|
|
219
|
+
self.set(**{name: value})
|
|
220
|
+
|
|
221
|
+
def add_class(self, *values: str) -> None:
|
|
222
|
+
"""
|
|
223
|
+
Adds one or more classes to the list of classes, if not already present.
|
|
224
|
+
|
|
225
|
+
Example:
|
|
226
|
+
|
|
227
|
+
```python
|
|
228
|
+
attrs = HTMLAttrs({"class": "a b c"})
|
|
229
|
+
attrs.add_class("c", "d")
|
|
230
|
+
attrs.as_dict
|
|
231
|
+
{"class": "a b c d"}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
"""
|
|
235
|
+
for names in values:
|
|
236
|
+
for name in split(names):
|
|
237
|
+
self.__classes.add(name)
|
|
238
|
+
|
|
239
|
+
def remove_class(self, *names: str) -> None:
|
|
240
|
+
"""
|
|
241
|
+
Removes one or more classes from the list of classes.
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
attrs = HTMLAttrs({"class": "a b c"})
|
|
247
|
+
attrs.remove_class("c", "d")
|
|
248
|
+
attrs.as_dict
|
|
249
|
+
{"class": "a b"}
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
"""
|
|
253
|
+
for name in names:
|
|
254
|
+
self.__classes.remove(name)
|
|
255
|
+
|
|
256
|
+
def get(self, name: str, default: t.Any = None) -> t.Any:
|
|
257
|
+
"""
|
|
258
|
+
Returns the value of the attribute or property,
|
|
259
|
+
or the default value if it doesn't exists.
|
|
260
|
+
|
|
261
|
+
Example:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
attrs = HTMLAttrs({"lorem": "ipsum", "hidden": True})
|
|
265
|
+
|
|
266
|
+
attrs.get("lorem", defaut="bar")
|
|
267
|
+
'ipsum'
|
|
268
|
+
|
|
269
|
+
attrs.get("foo")
|
|
270
|
+
None
|
|
271
|
+
|
|
272
|
+
attrs.get("foo", defaut="bar")
|
|
273
|
+
'bar'
|
|
274
|
+
|
|
275
|
+
attrs.get("hidden")
|
|
276
|
+
True
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
"""
|
|
280
|
+
name = name.replace("_", "-")
|
|
281
|
+
if name in CLASS_KEYS:
|
|
282
|
+
return self.classes
|
|
283
|
+
if name in self.__attributes:
|
|
284
|
+
return self.__attributes[name]
|
|
285
|
+
if name in self.__properties:
|
|
286
|
+
return True
|
|
287
|
+
return default
|
|
288
|
+
|
|
289
|
+
def render(self, **kw) -> str:
|
|
290
|
+
"""
|
|
291
|
+
Renders the attributes and properties as a string.
|
|
292
|
+
|
|
293
|
+
Any arguments you use with this function are merged with the existing
|
|
294
|
+
attibutes/properties by the same rules as the `HTMLAttrs.set()` function:
|
|
295
|
+
|
|
296
|
+
- Pass a name and a value to set an attribute (e.g. `type="text"`)
|
|
297
|
+
- Use `True` as a value to set a property (e.g. `disabled`)
|
|
298
|
+
- Use `False` to remove an attribute or property
|
|
299
|
+
- If the attribute is "class", the new classes are appended to
|
|
300
|
+
the old ones (if not repeated) instead of replacing them.
|
|
301
|
+
- The underscores in the names will be translated automatically to dashes,
|
|
302
|
+
so `aria_selected` becomes the attribute `aria-selected`.
|
|
303
|
+
|
|
304
|
+
To provide consistent output, the attributes and properties
|
|
305
|
+
are sorted by name and rendered like this:
|
|
306
|
+
`<sorted attributes> + <sorted properties>`.
|
|
307
|
+
|
|
308
|
+
Example:
|
|
309
|
+
|
|
310
|
+
```python
|
|
311
|
+
attrs = HTMLAttrs({"class": "ipsum", "data_good": True, "width": 42})
|
|
312
|
+
|
|
313
|
+
attrs.render()
|
|
314
|
+
'class="ipsum" width="42" data-good'
|
|
315
|
+
|
|
316
|
+
attrs.render(class="abc", data_good=False, tabindex=0)
|
|
317
|
+
'class="abc ipsum" width="42" tabindex="0"'
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
"""
|
|
321
|
+
if kw:
|
|
322
|
+
self.set(**kw)
|
|
323
|
+
|
|
324
|
+
attributes = self.__attributes.copy()
|
|
325
|
+
|
|
326
|
+
classes = self.classes
|
|
327
|
+
if classes:
|
|
328
|
+
attributes[CLASS_KEY] = classes
|
|
329
|
+
|
|
330
|
+
attributes = dict(sorted(attributes.items()))
|
|
331
|
+
properties = sorted((self.__properties))
|
|
332
|
+
|
|
333
|
+
html_attrs = [
|
|
334
|
+
f"{name}={quote(str(value))}"
|
|
335
|
+
for name, value in attributes.items()
|
|
336
|
+
]
|
|
337
|
+
html_attrs.extend(properties)
|
|
338
|
+
|
|
339
|
+
return Markup(" ".join(html_attrs))
|
|
340
|
+
|
|
341
|
+
# Private
|
|
342
|
+
|
|
343
|
+
def _remove(self, name: str) -> None:
|
|
344
|
+
"""
|
|
345
|
+
Removes an attribute or property.
|
|
346
|
+
"""
|
|
347
|
+
if name in CLASS_KEYS:
|
|
348
|
+
self.__classes = set()
|
|
349
|
+
if name in self.__attributes:
|
|
350
|
+
del self.__attributes[name]
|
|
351
|
+
if name in self.__properties:
|
|
352
|
+
self.__properties.remove(name)
|
jx/catalog.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
import typing as t
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import CodeType
|
|
8
|
+
|
|
9
|
+
import jinja2
|
|
10
|
+
|
|
11
|
+
from . import utils
|
|
12
|
+
from .component import Component
|
|
13
|
+
from .exceptions import ImportError
|
|
14
|
+
from .meta import extract_metadata
|
|
15
|
+
from .parser import JxParser
|
|
16
|
+
from .utils import logger
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class CData:
|
|
21
|
+
base_path: Path
|
|
22
|
+
path: Path
|
|
23
|
+
mtime: float
|
|
24
|
+
code: CodeType | None = None
|
|
25
|
+
required: tuple[str, ...] = ()
|
|
26
|
+
optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
|
|
27
|
+
imports: dict[str, str] = field(default_factory=dict) # { component_name: relpath }
|
|
28
|
+
css: tuple[str, ...] = ()
|
|
29
|
+
js: tuple[str, ...] = ()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Catalog:
|
|
33
|
+
"""
|
|
34
|
+
The object that manages the components and their global settings.
|
|
35
|
+
|
|
36
|
+
Arguments:
|
|
37
|
+
"""
|
|
38
|
+
components: dict[str, CData]
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
folder: str | Path | None = None,
|
|
43
|
+
*,
|
|
44
|
+
jinja_env: jinja2.Environment | None = None,
|
|
45
|
+
filters: dict[str, t.Any] | None = None,
|
|
46
|
+
tests: dict[str, t.Any] | None = None,
|
|
47
|
+
extensions: list | None = None,
|
|
48
|
+
auto_reload: bool = True,
|
|
49
|
+
**globals: t.Any,
|
|
50
|
+
) -> None:
|
|
51
|
+
self.components = {}
|
|
52
|
+
self.jinja_env = self.make_jinja_env(
|
|
53
|
+
jinja_env=jinja_env,
|
|
54
|
+
globals=globals,
|
|
55
|
+
filters=filters,
|
|
56
|
+
tests=tests,
|
|
57
|
+
extensions=extensions,
|
|
58
|
+
)
|
|
59
|
+
self.auto_reload = auto_reload
|
|
60
|
+
if folder:
|
|
61
|
+
self.add_folder(folder)
|
|
62
|
+
|
|
63
|
+
def make_jinja_env(
|
|
64
|
+
self,
|
|
65
|
+
*,
|
|
66
|
+
jinja_env: jinja2.Environment | None = None,
|
|
67
|
+
globals: dict[str, t.Any] | None = None,
|
|
68
|
+
filters: dict[str, t.Any] | None = None,
|
|
69
|
+
tests: dict[str, t.Any] | None = None,
|
|
70
|
+
extensions: list | None = None,
|
|
71
|
+
) -> jinja2.Environment:
|
|
72
|
+
env = jinja2.Environment()
|
|
73
|
+
jinja_env = jinja_env or getattr(self, "jinja_env", None)
|
|
74
|
+
if jinja_env:
|
|
75
|
+
env = jinja_env.overlay()
|
|
76
|
+
|
|
77
|
+
globals = globals or {}
|
|
78
|
+
globals.update({
|
|
79
|
+
"_get_random_id": utils.get_random_id,
|
|
80
|
+
})
|
|
81
|
+
env.globals.update(globals)
|
|
82
|
+
|
|
83
|
+
filters = filters or {}
|
|
84
|
+
env.filters.update(filters)
|
|
85
|
+
|
|
86
|
+
tests = tests or {}
|
|
87
|
+
env.tests.update(tests)
|
|
88
|
+
|
|
89
|
+
extensions = extensions or []
|
|
90
|
+
extensions.extend(["jinja2.ext.do"])
|
|
91
|
+
for ext in extensions:
|
|
92
|
+
env.add_extension(ext)
|
|
93
|
+
|
|
94
|
+
env.autoescape = True
|
|
95
|
+
env.undefined = jinja2.StrictUndefined
|
|
96
|
+
|
|
97
|
+
return env
|
|
98
|
+
|
|
99
|
+
def add_folder(
|
|
100
|
+
self,
|
|
101
|
+
path: str | Path,
|
|
102
|
+
*,
|
|
103
|
+
prefix: str = "",
|
|
104
|
+
) -> None:
|
|
105
|
+
"""
|
|
106
|
+
Add a folder path from which to search for components, optionally under a prefix.
|
|
107
|
+
|
|
108
|
+
Arguments:
|
|
109
|
+
|
|
110
|
+
path:
|
|
111
|
+
Absolute path of the folder with component files.
|
|
112
|
+
|
|
113
|
+
prefix:
|
|
114
|
+
Optional path prefix that all the components in the folder
|
|
115
|
+
will have. The default is empty.
|
|
116
|
+
|
|
117
|
+
Components without a prefix can be imported as a path relative to the folder,
|
|
118
|
+
e.g.: `sub/folder/component.jinja` or with a path relative to the component
|
|
119
|
+
where it is used: `./folder/component.jinja`.
|
|
120
|
+
|
|
121
|
+
Components added with a prefix must be imported using the prefix followed
|
|
122
|
+
by a colon: `prefix:sub/folder/component.jinja`. If the importing is
|
|
123
|
+
done from within a component with the prefix itself, a relative
|
|
124
|
+
import can also be used, e.g.: `./component.jinja`.
|
|
125
|
+
|
|
126
|
+
All the folders added under the same prefix will be treated as if they
|
|
127
|
+
were a single folder. This means if you add two folders, under the same prefix,
|
|
128
|
+
with a component with the same subpath/filename, the one in the folder
|
|
129
|
+
added **first** will be used and the other ignored.
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
base_path = Path(path).resolve()
|
|
133
|
+
prefix = prefix.replace("\\", "/").strip("./@ ")
|
|
134
|
+
prefix = f"@{prefix}/" if prefix else ""
|
|
135
|
+
if prefix:
|
|
136
|
+
logger.debug(f"Adding folder `{base_path}` with the prefix `{prefix}`")
|
|
137
|
+
else:
|
|
138
|
+
logger.debug(f"Adding folder `{base_path}`")
|
|
139
|
+
|
|
140
|
+
for filepath in base_path.rglob("*.jinja"):
|
|
141
|
+
relpath = f"{prefix}{filepath.relative_to(base_path).as_posix()}"
|
|
142
|
+
if relpath in self.components:
|
|
143
|
+
logger.debug(f"Component already exists: {relpath}")
|
|
144
|
+
continue
|
|
145
|
+
cdata = CData(
|
|
146
|
+
base_path=base_path,
|
|
147
|
+
path=filepath,
|
|
148
|
+
mtime=filepath.stat().st_mtime
|
|
149
|
+
)
|
|
150
|
+
self.components[relpath] = cdata
|
|
151
|
+
|
|
152
|
+
for relpath in self.components:
|
|
153
|
+
self.components[relpath] = self.get_component_data(relpath)
|
|
154
|
+
|
|
155
|
+
def get_component_data(self, relpath: str) -> CData:
|
|
156
|
+
cdata = self.components.get(relpath)
|
|
157
|
+
if not cdata:
|
|
158
|
+
raise ImportError(relpath)
|
|
159
|
+
|
|
160
|
+
mtime = cdata.path.stat().st_mtime if self.auto_reload else 0
|
|
161
|
+
if cdata.code is not None:
|
|
162
|
+
if self.auto_reload:
|
|
163
|
+
if mtime == cdata.mtime:
|
|
164
|
+
return cdata
|
|
165
|
+
else:
|
|
166
|
+
return cdata
|
|
167
|
+
|
|
168
|
+
source = cdata.path.read_text()
|
|
169
|
+
meta = extract_metadata(source, base_path=cdata.base_path, fullpath=cdata.path)
|
|
170
|
+
|
|
171
|
+
parser = JxParser(
|
|
172
|
+
name=relpath,
|
|
173
|
+
source=source,
|
|
174
|
+
components=list(meta.imports.keys())
|
|
175
|
+
)
|
|
176
|
+
parsed_source = parser.parse()
|
|
177
|
+
code = self.jinja_env.compile(
|
|
178
|
+
source=parsed_source,
|
|
179
|
+
name=relpath,
|
|
180
|
+
filename=cdata.path.as_posix()
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
cdata.code = code
|
|
184
|
+
cdata.required = meta.required
|
|
185
|
+
cdata.optional = meta.optional
|
|
186
|
+
cdata.imports = meta.imports
|
|
187
|
+
cdata.css = meta.css
|
|
188
|
+
cdata.js = meta.js
|
|
189
|
+
return cdata
|
|
190
|
+
|
|
191
|
+
def get_component(self, relpath: str) -> Component:
|
|
192
|
+
cdata = self.get_component_data(relpath)
|
|
193
|
+
assert cdata.code is not None
|
|
194
|
+
tmpl = jinja2.Template.from_code(
|
|
195
|
+
self.jinja_env,
|
|
196
|
+
cdata.code,
|
|
197
|
+
self.jinja_env.globals
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
co = Component(
|
|
201
|
+
relpath=relpath,
|
|
202
|
+
tmpl=tmpl,
|
|
203
|
+
get_component=self.get_component,
|
|
204
|
+
required=cdata.required,
|
|
205
|
+
optional=cdata.optional,
|
|
206
|
+
css=cdata.css,
|
|
207
|
+
js=cdata.js,
|
|
208
|
+
imports=cdata.imports
|
|
209
|
+
)
|
|
210
|
+
return co
|
|
211
|
+
|
|
212
|
+
def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
|
|
213
|
+
co = self.get_component(relpath)
|
|
214
|
+
|
|
215
|
+
globals = globals or {}
|
|
216
|
+
globals.update({
|
|
217
|
+
"assets": {
|
|
218
|
+
"css": co.collect_css,
|
|
219
|
+
"js": co.collect_js,
|
|
220
|
+
"render_css": co.render_css,
|
|
221
|
+
"render_js": co.render_js,
|
|
222
|
+
"render": co.render_assets,
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
co.globals = globals
|
|
226
|
+
|
|
227
|
+
return co.render(**kwargs)
|
jx/component.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import typing as t
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
|
|
8
|
+
import jinja2
|
|
9
|
+
from markupsafe import Markup
|
|
10
|
+
|
|
11
|
+
from .attrs import Attrs
|
|
12
|
+
from .exceptions import MissingRequiredArgument
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
rx_external_url = re.compile(r"^[a-z]+://", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Component:
|
|
19
|
+
__slots__ = (
|
|
20
|
+
"relpath",
|
|
21
|
+
"tmpl",
|
|
22
|
+
"get_component",
|
|
23
|
+
"required",
|
|
24
|
+
"optional",
|
|
25
|
+
"css",
|
|
26
|
+
"js",
|
|
27
|
+
"imports",
|
|
28
|
+
"globals",
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
relpath: str,
|
|
35
|
+
tmpl: jinja2.Template,
|
|
36
|
+
get_component: Callable[[str], "Component"],
|
|
37
|
+
required: tuple[str, ...] = (),
|
|
38
|
+
optional: dict[str, t.Any] | None = None,
|
|
39
|
+
css: tuple[str, ...] = (),
|
|
40
|
+
js: tuple[str, ...] = (),
|
|
41
|
+
imports: dict[str, str] | None = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
self.relpath = relpath
|
|
44
|
+
self.tmpl = tmpl
|
|
45
|
+
self.get_component = get_component
|
|
46
|
+
|
|
47
|
+
self.required = required
|
|
48
|
+
self.optional = optional or {}
|
|
49
|
+
self.css = css
|
|
50
|
+
self.js = js
|
|
51
|
+
self.imports = imports or {}
|
|
52
|
+
|
|
53
|
+
self.globals: dict[str, t.Any] = {}
|
|
54
|
+
|
|
55
|
+
def render(
|
|
56
|
+
self,
|
|
57
|
+
*,
|
|
58
|
+
content: str | None = None,
|
|
59
|
+
attrs: Attrs | dict[str, t.Any] | None = None,
|
|
60
|
+
caller: Callable[[], str] | None = None,
|
|
61
|
+
**params: t.Any
|
|
62
|
+
) -> Markup:
|
|
63
|
+
content = content if content is not None else caller() if caller else ""
|
|
64
|
+
attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
|
|
65
|
+
params = {**attrs, **params}
|
|
66
|
+
props, attrs = self.filter_attrs(params)
|
|
67
|
+
|
|
68
|
+
globals = {**self.globals, "_get": self.get_child}
|
|
69
|
+
globals.setdefault("attrs", Attrs(attrs))
|
|
70
|
+
globals.setdefault("content", content)
|
|
71
|
+
|
|
72
|
+
html = self.tmpl.render({**props, **globals}).lstrip()
|
|
73
|
+
return Markup(html)
|
|
74
|
+
|
|
75
|
+
def filter_attrs(
|
|
76
|
+
self, kw: dict[str, t.Any]
|
|
77
|
+
) -> tuple[dict[str, t.Any], dict[str, t.Any]]:
|
|
78
|
+
props = {}
|
|
79
|
+
|
|
80
|
+
for key in self.required:
|
|
81
|
+
if key not in kw:
|
|
82
|
+
raise MissingRequiredArgument(self.relpath, key)
|
|
83
|
+
props[key] = kw.pop(key)
|
|
84
|
+
|
|
85
|
+
for key in self.optional:
|
|
86
|
+
props[key] = kw.pop(key, self.optional[key])
|
|
87
|
+
extra = kw.copy()
|
|
88
|
+
return props, extra
|
|
89
|
+
|
|
90
|
+
def get_child(self, name: str) -> "Component":
|
|
91
|
+
relpath = self.imports[name]
|
|
92
|
+
child = self.get_component(relpath)
|
|
93
|
+
child.globals = self.globals
|
|
94
|
+
return child
|
|
95
|
+
|
|
96
|
+
def collect_css(self, visited: set[str] | None = None) -> list[str]:
|
|
97
|
+
"""
|
|
98
|
+
Returns a list of CSS files for the component and its children.
|
|
99
|
+
"""
|
|
100
|
+
urls = dict.fromkeys(self.css, 1)
|
|
101
|
+
visited = visited or set()
|
|
102
|
+
visited.add(self.relpath)
|
|
103
|
+
|
|
104
|
+
for name, relpath in self.imports.items():
|
|
105
|
+
if relpath in visited:
|
|
106
|
+
continue
|
|
107
|
+
co = self.get_child(name)
|
|
108
|
+
for file in co.collect_css(visited=visited):
|
|
109
|
+
if file not in urls:
|
|
110
|
+
urls[file] = 1
|
|
111
|
+
visited.add(relpath)
|
|
112
|
+
|
|
113
|
+
return list(urls.keys())
|
|
114
|
+
|
|
115
|
+
def collect_js(self, visited: set[str] | None = None) -> list[str]:
|
|
116
|
+
"""
|
|
117
|
+
Returns a list of JS files for the component and its children.
|
|
118
|
+
"""
|
|
119
|
+
urls = dict.fromkeys(self.js, 1)
|
|
120
|
+
visited = visited or set()
|
|
121
|
+
visited.add(self.relpath)
|
|
122
|
+
|
|
123
|
+
for name, relpath in self.imports.items():
|
|
124
|
+
if relpath in visited:
|
|
125
|
+
continue
|
|
126
|
+
co = self.get_child(name)
|
|
127
|
+
for file in co.collect_js(visited=visited):
|
|
128
|
+
if file not in urls:
|
|
129
|
+
urls[file] = 1
|
|
130
|
+
visited.add(relpath)
|
|
131
|
+
|
|
132
|
+
return list(urls.keys())
|
|
133
|
+
|
|
134
|
+
def render_css(self) -> Markup:
|
|
135
|
+
"""
|
|
136
|
+
Uses the `collect_css()` list to generate an HTML fragment
|
|
137
|
+
with `<link rel="stylesheet" href="{url}">` tags.
|
|
138
|
+
|
|
139
|
+
Unless it's an external URL (e.g.: beginning with "http://" or "https://")
|
|
140
|
+
or a root-relative URL (e.g.: starting with "/"),
|
|
141
|
+
the URL is prefixed by `base_url`.
|
|
142
|
+
"""
|
|
143
|
+
html = []
|
|
144
|
+
for url in self.collect_css():
|
|
145
|
+
html.append(f'<link rel="stylesheet" href="{url}">')
|
|
146
|
+
|
|
147
|
+
return Markup("\n".join(html))
|
|
148
|
+
|
|
149
|
+
def render_js(self, module: bool = True, defer: bool = True) -> Markup:
|
|
150
|
+
"""
|
|
151
|
+
Uses the `collected_js()` list to generate an HTML fragment
|
|
152
|
+
with `<script type="module" src="{url}"></script>` tags.
|
|
153
|
+
|
|
154
|
+
Unless it's an external URL (e.g.: beginning with "http://" or "https://"),
|
|
155
|
+
the URL is prefixed by `base_url`. A hash can also be added to
|
|
156
|
+
invalidate the cache if the content changes, if `fingerprint` is `True`.
|
|
157
|
+
"""
|
|
158
|
+
html = []
|
|
159
|
+
for url in self.collect_js():
|
|
160
|
+
if module:
|
|
161
|
+
tag = f'<script type="module" src="{url}"></script>'
|
|
162
|
+
elif defer:
|
|
163
|
+
tag = f'<script src="{url}" defer></script>'
|
|
164
|
+
else:
|
|
165
|
+
tag = f'<script src="{url}"></script>'
|
|
166
|
+
html.append(tag)
|
|
167
|
+
|
|
168
|
+
return Markup("\n".join(html))
|
|
169
|
+
|
|
170
|
+
def render_assets(self, module: bool = True, defer: bool = False) -> Markup:
|
|
171
|
+
"""
|
|
172
|
+
Calls `render_css()` and `render_js()` to generate
|
|
173
|
+
an HTML fragment with `<link rel="stylesheet" href="{url}">`
|
|
174
|
+
and `<script type="module" src="{url}"></script>` tags.
|
|
175
|
+
Unless it's an external URL (e.g.: beginning with "http://" or "https://"),
|
|
176
|
+
the URL is prefixed by `base_url`. A hash can also be added to
|
|
177
|
+
invalidate the cache if the content changes, if `fingerprint` is `True`.
|
|
178
|
+
"""
|
|
179
|
+
html_css = self.render_css()
|
|
180
|
+
html_js = self.render_js()
|
|
181
|
+
return Markup(("\n".join([html_css, html_js]).strip()))
|
jx/exceptions.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
class JxException(Exception):
|
|
6
|
+
"""Base class for all Jx exceptions."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TemplateSyntaxError(JxException):
|
|
10
|
+
"""
|
|
11
|
+
Raised when the template syntax is invalid.
|
|
12
|
+
This is usually caused by a missing or extra closing tag.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ImportError(JxException):
|
|
17
|
+
"""
|
|
18
|
+
Raised when an import fails.
|
|
19
|
+
This is usually caused by a missing or inaccessible component.
|
|
20
|
+
"""
|
|
21
|
+
def __init__(self, relpath: str, **kw) -> None:
|
|
22
|
+
msg = f"Component not found: {relpath}"
|
|
23
|
+
super().__init__(msg, **kw)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MissingRequiredArgument(JxException):
|
|
27
|
+
"""
|
|
28
|
+
Raised when a component is used/invoked without passing one or more
|
|
29
|
+
of its required arguments (those without a default value).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, component: str, arg: str, **kw) -> None:
|
|
33
|
+
msg = f"{component} component requires a `{arg}` argument"
|
|
34
|
+
super().__init__(msg, **kw)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DuplicateDefDeclaration(JxException):
|
|
38
|
+
"""
|
|
39
|
+
Raised when a component has more then one `{#def ... #}` declarations.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self, component: str, **kw) -> None:
|
|
43
|
+
msg = f"{component} has two `{{#def ... #}}` declarations"
|
|
44
|
+
super().__init__(msg, **kw)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class InvalidArgument(JxException):
|
|
48
|
+
"""
|
|
49
|
+
Raised when the arguments passed to the component cannot be parsed
|
|
50
|
+
because of an invalid syntax.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InvalidImport(JxException):
|
|
55
|
+
"""
|
|
56
|
+
Raised when the import cannot be parsed
|
|
57
|
+
"""
|
jx/meta.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
import ast
|
|
5
|
+
import re
|
|
6
|
+
import typing as t
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .exceptions import DuplicateDefDeclaration, InvalidArgument, InvalidImport
|
|
11
|
+
from .parser import re_tag_name
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# This regexp matches the meta declarations (`{#def .. #}``, `{#css .. #}``,
|
|
15
|
+
# and `{#js .. #}`) and regular Jinja comments AT THE BEGINNING of the components source.
|
|
16
|
+
# You can also have comments inside the declarations.
|
|
17
|
+
RX_META_HEADER = re.compile(r"^(\s*{#.*?#})+", re.DOTALL)
|
|
18
|
+
|
|
19
|
+
# This regexep matches comments (everything after a `#`)
|
|
20
|
+
# Used to remove them from inside meta declarations
|
|
21
|
+
RX_INTER_COMMENTS = re.compile(r"\s*#[^\n]*")
|
|
22
|
+
|
|
23
|
+
RX_DEF_START = re.compile(r"{#-?\s*def\s+")
|
|
24
|
+
RX_IMPORT_START = re.compile(r"{#-?\s*import\s+")
|
|
25
|
+
RX_CSS_START = re.compile(r"{#-?\s*css\s+")
|
|
26
|
+
RX_JS_START = re.compile(r"{#-?\s*js\s+")
|
|
27
|
+
RX_COMMA = re.compile(r"\s*,\s*")
|
|
28
|
+
RX_IMPORT = re.compile(fr'"([^"]+)"\s+as\s+({re_tag_name})')
|
|
29
|
+
|
|
30
|
+
ALLOWED_NAMES_IN_EXPRESSION_VALUES = {
|
|
31
|
+
"len": len,
|
|
32
|
+
"max": max,
|
|
33
|
+
"min": min,
|
|
34
|
+
"pow": pow,
|
|
35
|
+
"sum": sum,
|
|
36
|
+
# Jinja allows using lowercase booleans, so we do it too for consistency
|
|
37
|
+
"false": False,
|
|
38
|
+
"true": True,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(slots=True)
|
|
43
|
+
class Meta:
|
|
44
|
+
required: tuple[str, ...] = ()
|
|
45
|
+
optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
|
|
46
|
+
imports: dict[str, str] = field(default_factory=dict) # { component_name: relpath }
|
|
47
|
+
css: tuple[str, ...] = ()
|
|
48
|
+
js: tuple[str, ...] = ()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def extract_metadata(source: str, base_path: Path, fullpath: Path) -> Meta:
|
|
52
|
+
"""
|
|
53
|
+
Extract metadata from the Jx template source.
|
|
54
|
+
|
|
55
|
+
Arguments:
|
|
56
|
+
source:
|
|
57
|
+
The template source code.
|
|
58
|
+
base_path:
|
|
59
|
+
Absolute base path for all the template files
|
|
60
|
+
fullpath:
|
|
61
|
+
The absolute full path of the current template.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
A Meta object containing the extracted metadata.
|
|
65
|
+
"""
|
|
66
|
+
meta = Meta()
|
|
67
|
+
|
|
68
|
+
match = RX_META_HEADER.match(source)
|
|
69
|
+
if not match:
|
|
70
|
+
return meta
|
|
71
|
+
|
|
72
|
+
header = match.group(0)
|
|
73
|
+
# Reversed because I will use `header.pop()`
|
|
74
|
+
header = header.split("#}")[:-1][::-1]
|
|
75
|
+
def_found = False
|
|
76
|
+
|
|
77
|
+
while header:
|
|
78
|
+
item = header.pop().strip(" -\n")
|
|
79
|
+
|
|
80
|
+
expr = read_metadata_item(item, RX_DEF_START)
|
|
81
|
+
if expr:
|
|
82
|
+
if def_found:
|
|
83
|
+
raise DuplicateDefDeclaration(str(fullpath))
|
|
84
|
+
meta.required, meta.optional = parse_args_expr(expr)
|
|
85
|
+
def_found = True
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
expr = read_metadata_item(item, RX_IMPORT_START)
|
|
89
|
+
if expr:
|
|
90
|
+
expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
|
|
91
|
+
import_path, import_name = parse_import_expr(expr)
|
|
92
|
+
if import_path.startswith("."):
|
|
93
|
+
import_path = (fullpath.parent / import_path).resolve().relative_to(base_path).as_posix()
|
|
94
|
+
meta.imports[import_name] = import_path
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
expr = read_metadata_item(item, RX_CSS_START)
|
|
98
|
+
if expr:
|
|
99
|
+
expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
|
|
100
|
+
meta.css = (*meta.css, *parse_files_expr(expr))
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
expr = read_metadata_item(item, RX_JS_START)
|
|
104
|
+
if expr:
|
|
105
|
+
expr = RX_INTER_COMMENTS.sub("", expr).replace("\n", " ")
|
|
106
|
+
meta.js = (*meta.js, *parse_files_expr(expr))
|
|
107
|
+
continue
|
|
108
|
+
|
|
109
|
+
return meta
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def read_metadata_item(source: str, rx_start: re.Pattern) -> str:
|
|
113
|
+
start = rx_start.match(source)
|
|
114
|
+
if not start:
|
|
115
|
+
return ""
|
|
116
|
+
return source[start.end():].strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
|
|
120
|
+
expr = expr.strip(" *,/")
|
|
121
|
+
required = []
|
|
122
|
+
optional = {}
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
p = ast.parse(f"def component(*,\n{expr}\n): pass")
|
|
126
|
+
except SyntaxError as err:
|
|
127
|
+
raise InvalidArgument(err) from err
|
|
128
|
+
|
|
129
|
+
args = p.body[0].args # type: ignore
|
|
130
|
+
arg_names = [arg.arg for arg in args.kwonlyargs]
|
|
131
|
+
for name, value in zip(arg_names, args.kw_defaults): # noqa: B905
|
|
132
|
+
if value is None:
|
|
133
|
+
required.append(name)
|
|
134
|
+
continue
|
|
135
|
+
expr = ast.unparse(value)
|
|
136
|
+
optional[name] = eval_expression(expr)
|
|
137
|
+
|
|
138
|
+
return tuple(required), optional
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def eval_expression(input_string):
|
|
142
|
+
code = compile(input_string, "<string>", "eval")
|
|
143
|
+
for name in code.co_names:
|
|
144
|
+
if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES:
|
|
145
|
+
raise InvalidArgument(f"Use of {name} not allowed")
|
|
146
|
+
return eval(code, {"__builtins__": {}}, ALLOWED_NAMES_IN_EXPRESSION_VALUES)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_files_expr(expr: str) -> list[str]:
|
|
150
|
+
files = []
|
|
151
|
+
for url in RX_COMMA.split(expr):
|
|
152
|
+
url = url.strip("\"'").rstrip("/")
|
|
153
|
+
if not url:
|
|
154
|
+
continue
|
|
155
|
+
if url.startswith(("/", "http://", "https://")):
|
|
156
|
+
files.append(url)
|
|
157
|
+
else:
|
|
158
|
+
files.append(url)
|
|
159
|
+
return files
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def parse_import_expr(expr: str) -> tuple[str, str]:
|
|
163
|
+
match = RX_IMPORT.match(expr)
|
|
164
|
+
if not match:
|
|
165
|
+
raise InvalidImport(expr)
|
|
166
|
+
return match.group(1), match.group(2)
|
jx/parser.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
|
+
"""
|
|
4
|
+
import re
|
|
5
|
+
import typing as t
|
|
6
|
+
from uuid import uuid4
|
|
7
|
+
|
|
8
|
+
from markupsafe import Markup
|
|
9
|
+
|
|
10
|
+
from .exceptions import TemplateSyntaxError
|
|
11
|
+
from .utils import logger
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
BLOCK_CALL = '{% call _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
|
|
15
|
+
INLINE_CALL = '{{ _get("[TAG]").render([ATTRS]) }}'
|
|
16
|
+
|
|
17
|
+
re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
|
|
18
|
+
RX_RAW = re.compile(re_raw, re.DOTALL)
|
|
19
|
+
|
|
20
|
+
re_tag_name = r"[A-Z][0-9A-Za-z_.:$-]*"
|
|
21
|
+
RX_TAG_NAME = re.compile(rf"<(?P<tag>{re_tag_name})(\s|\n|/|>)")
|
|
22
|
+
|
|
23
|
+
re_attr_name = r""
|
|
24
|
+
re_equal = r""
|
|
25
|
+
re_attr = r"""
|
|
26
|
+
(?P<name>[:a-zA-Z@$_][a-zA-Z@:$_0-9-]*)
|
|
27
|
+
(?:
|
|
28
|
+
\s*=\s*
|
|
29
|
+
(?P<value>".*?"|'.*?'|\{\{.*?\}\})
|
|
30
|
+
)?
|
|
31
|
+
(?:\s+|/|"|$)
|
|
32
|
+
"""
|
|
33
|
+
RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def escape(s: t.Any, /) -> Markup:
|
|
37
|
+
return Markup(
|
|
38
|
+
str(s)
|
|
39
|
+
.replace("&", "&")
|
|
40
|
+
.replace(">", ">")
|
|
41
|
+
.replace("<", "<")
|
|
42
|
+
.replace("'", "'")
|
|
43
|
+
.replace('"', """)
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JxParser:
|
|
48
|
+
def __init__(self, *, name: str, source: str, components: list[str]) :
|
|
49
|
+
self.name = name
|
|
50
|
+
self.source = source
|
|
51
|
+
self.components = components
|
|
52
|
+
|
|
53
|
+
def parse(self, *, validate_tags: bool = True) -> str:
|
|
54
|
+
raw_blocks = {}
|
|
55
|
+
source = self.source
|
|
56
|
+
source, raw_blocks = self.replace_raw_blocks(source)
|
|
57
|
+
source = self.process_tags(source, validate_tags=validate_tags)
|
|
58
|
+
source = self.restore_raw_blocks(source, raw_blocks)
|
|
59
|
+
return source
|
|
60
|
+
|
|
61
|
+
def replace_raw_blocks(self, source: str) -> tuple[str, dict[str, str]]:
|
|
62
|
+
raw_blocks = {}
|
|
63
|
+
while True:
|
|
64
|
+
match = RX_RAW.search(source)
|
|
65
|
+
if not match:
|
|
66
|
+
break
|
|
67
|
+
start, end = match.span(0)
|
|
68
|
+
repl = escape(match.group(0))
|
|
69
|
+
key = f"--RAW-{uuid4().hex}--"
|
|
70
|
+
raw_blocks[key] = repl
|
|
71
|
+
source = f"{source[:start]}{key}{source[end:]}"
|
|
72
|
+
|
|
73
|
+
return source, raw_blocks
|
|
74
|
+
|
|
75
|
+
def restore_raw_blocks(self, source: str, raw_blocks: dict[str, str]) -> str:
|
|
76
|
+
for uid, code in raw_blocks.items():
|
|
77
|
+
source = source.replace(uid, code)
|
|
78
|
+
return source
|
|
79
|
+
|
|
80
|
+
def process_tags(self, source: str, *, validate_tags: bool = True) -> str:
|
|
81
|
+
while True:
|
|
82
|
+
match = RX_TAG_NAME.search(source)
|
|
83
|
+
if not match:
|
|
84
|
+
break
|
|
85
|
+
source = self.replace_tag(source, match, validate_tags=validate_tags)
|
|
86
|
+
return source
|
|
87
|
+
|
|
88
|
+
def replace_tag(self, source: str, match: re.Match, *, validate_tags: bool = True) -> str:
|
|
89
|
+
start, curr = match.span(0)
|
|
90
|
+
lineno = source[:start].count("\n") + 1
|
|
91
|
+
|
|
92
|
+
tag = match.group("tag")
|
|
93
|
+
if validate_tags and tag not in self.components:
|
|
94
|
+
line = self.source.split("\n")[lineno - 1]
|
|
95
|
+
raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unknown component `{tag}`\n{line}")
|
|
96
|
+
|
|
97
|
+
attrs, end = self._parse_opening_tag(source, lineno=lineno, start=curr - 1)
|
|
98
|
+
if end == -1:
|
|
99
|
+
line = self.source.split("\n")[lineno - 1]
|
|
100
|
+
raise TemplateSyntaxError(f"[{self.name}:{lineno}] Syntax error: `{tag}`\n{line}")
|
|
101
|
+
|
|
102
|
+
inline = source[end - 2:end] == "/>"
|
|
103
|
+
if inline:
|
|
104
|
+
content = ""
|
|
105
|
+
else:
|
|
106
|
+
close_tag = f"</{tag}>"
|
|
107
|
+
index = source.find(close_tag, end, None)
|
|
108
|
+
if index == -1:
|
|
109
|
+
line = self.source.split("\n")[lineno - 1]
|
|
110
|
+
raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unclosed component `{tag}`\n{line}")
|
|
111
|
+
|
|
112
|
+
content = source[end:index]
|
|
113
|
+
end = index + len(close_tag)
|
|
114
|
+
|
|
115
|
+
attrs_list = self._parse_attrs(attrs)
|
|
116
|
+
repl = self._build_call(tag, attrs_list, content)
|
|
117
|
+
|
|
118
|
+
return f"{source[:start]}{repl}{source[end:]}"
|
|
119
|
+
|
|
120
|
+
def _parse_opening_tag(self, source: str, *, lineno: int, start: int) -> tuple[str, int]:
|
|
121
|
+
eof = len(source)
|
|
122
|
+
in_single_quotes = in_double_quotes = in_braces = False # dentro de '…' / "…"
|
|
123
|
+
i = start
|
|
124
|
+
end = -1
|
|
125
|
+
|
|
126
|
+
while i < eof:
|
|
127
|
+
ch = source[i]
|
|
128
|
+
ch2 = source[i:i + 2]
|
|
129
|
+
# print(ch, ch2, in_single_quotes, in_double_quotes, in_braces)
|
|
130
|
+
|
|
131
|
+
# Detects {{ … }} only when NOT inside quotes
|
|
132
|
+
if not in_single_quotes and not in_double_quotes:
|
|
133
|
+
if ch2 == "{{":
|
|
134
|
+
if in_braces:
|
|
135
|
+
raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unmatched braces")
|
|
136
|
+
in_braces = True
|
|
137
|
+
i += 2
|
|
138
|
+
continue
|
|
139
|
+
|
|
140
|
+
if ch2 == "}}":
|
|
141
|
+
if not in_braces:
|
|
142
|
+
raise TemplateSyntaxError(f"[{self.name}:{lineno}] Unmatched braces")
|
|
143
|
+
in_braces = False
|
|
144
|
+
i += 2
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
if ch == "'" and not in_double_quotes:
|
|
148
|
+
in_single_quotes = not in_single_quotes
|
|
149
|
+
i += 1
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
if ch == '"' and not in_single_quotes:
|
|
153
|
+
in_double_quotes = not in_double_quotes
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# End of the tag: ‘>’ outside of quotes and outside of {{ … }}
|
|
158
|
+
if ch == ">" and not (in_single_quotes or in_double_quotes or in_braces):
|
|
159
|
+
end = i + 1
|
|
160
|
+
break
|
|
161
|
+
|
|
162
|
+
i += 1
|
|
163
|
+
|
|
164
|
+
attrs = source[start:end].strip().removesuffix("/>").removesuffix(">")
|
|
165
|
+
return attrs, end
|
|
166
|
+
|
|
167
|
+
def _parse_attrs(self, attrs: str) -> list[tuple[str, str]]:
|
|
168
|
+
attrs = attrs.replace("\n", " ").strip()
|
|
169
|
+
if not attrs:
|
|
170
|
+
return []
|
|
171
|
+
return RX_ATTR.findall(attrs)
|
|
172
|
+
|
|
173
|
+
def _build_call(
|
|
174
|
+
self,
|
|
175
|
+
tag: str,
|
|
176
|
+
attrs_list: list[tuple[str, str]],
|
|
177
|
+
content: str = "",
|
|
178
|
+
) -> str:
|
|
179
|
+
logger.debug(f"{tag} {attrs_list} {'inline' if not content else ''}")
|
|
180
|
+
attrs = []
|
|
181
|
+
for name, value in attrs_list:
|
|
182
|
+
name = name.strip().replace("-", "_")
|
|
183
|
+
value = value.strip()
|
|
184
|
+
|
|
185
|
+
if not value:
|
|
186
|
+
attrs.append(f'"{name}"=True')
|
|
187
|
+
else:
|
|
188
|
+
# vue-like syntax
|
|
189
|
+
# if (
|
|
190
|
+
# name[0] == ":"
|
|
191
|
+
# and value[0] in ("\"'")
|
|
192
|
+
# and value[-1] in ("\"'")
|
|
193
|
+
# ):
|
|
194
|
+
# value = value[1:-1].strip()
|
|
195
|
+
# name = name.lstrip(":")
|
|
196
|
+
|
|
197
|
+
# double curly braces syntax
|
|
198
|
+
if value[:2] == "{{" and value[-2:] == "}}":
|
|
199
|
+
value = value[2:-2].strip()
|
|
200
|
+
|
|
201
|
+
attrs.append(f'"{name}"={value}')
|
|
202
|
+
|
|
203
|
+
str_attrs = ""
|
|
204
|
+
if attrs:
|
|
205
|
+
str_attrs = "**{" + ", ".join([a.replace("=", ":", 1) for a in attrs]) + "}"
|
|
206
|
+
|
|
207
|
+
if content:
|
|
208
|
+
return (
|
|
209
|
+
BLOCK_CALL
|
|
210
|
+
.replace("[TAG]", tag)
|
|
211
|
+
.replace("[ATTRS]", str_attrs)
|
|
212
|
+
.replace("[CONTENT]", content)
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
return INLINE_CALL.replace("[TAG]", tag).replace("[ATTRS]", str_attrs)
|
jx/utils.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jx
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Replace your HTML templates with Python server-Side components
|
|
5
|
+
Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
6
|
+
Project-URL: homepage, https://jx.scaletti.dev/
|
|
7
|
+
Project-URL: repository, https://github.com/jpsca/jx
|
|
8
|
+
Project-URL: documentation, https://jx.scaletti.dev/
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Web Environment
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
20
|
+
Classifier: Topic :: Text Processing :: Markup :: HTML
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: <4,>=3.11
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
License-File: LICENSE
|
|
25
|
+
Requires-Dist: jinja2>=3.0
|
|
26
|
+
Requires-Dist: markupsafe>=2.0
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
<h1>
|
|
30
|
+
<img src="https://github.com/jpsca/jx/raw/main/docs/logo-jx.png" height="100" align="top">
|
|
31
|
+
</h1>
|
|
32
|
+
|
|
33
|
+
Super components powers for your Jinja templates.
|
|
34
|
+
|
|
35
|
+
<p>
|
|
36
|
+
<img alt="python: 3.11, 3.12, 3.13, 3.14" src="https://github.com/jpsca/jx/raw/main/docs/python.svg">
|
|
37
|
+
<img alt="license: MIT" src="https://github.com/jpsca/jx/raw/main/docs/license.svg">
|
|
38
|
+
</p>
|
|
39
|
+
|
|
40
|
+
From chaos to clarity: The power of components in your server-side-rendered Python web app.
|
|
41
|
+
|
|
42
|
+
<!-- Documentation: https://jx.scaletti.dev/ -->
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
jx/__init__.py,sha256=MZ7KbNZkQx2D5CJpccSKuyJi179iAff3a9jG7DwEBAE,152
|
|
2
|
+
jx/attrs.py,sha256=zjcZhaVxpgqE0nAE-kDDqKLG3J_AuZg_q4pk6iHy9FE,10054
|
|
3
|
+
jx/catalog.py,sha256=0YDOz3QJnQC2MMCKfL_Zbkrff7CQaK4M-DCMh2lA6Cs,7037
|
|
4
|
+
jx/component.py,sha256=Iv4ixTbof02N3cAXW6QcluoT30vFe3aHaRRWEJX1znw,5845
|
|
5
|
+
jx/exceptions.py,sha256=rDWxkgOqeMPptmQ21joB2ujfpIU5EwMKtAL-ImB9nuA,1518
|
|
6
|
+
jx/meta.py,sha256=8PWv09laXiIb-6rwcJ0KFT0rmuIpOfR59wiH_GZ1hZw,5131
|
|
7
|
+
jx/parser.py,sha256=BlOcLH83A0xxbzQYijzbGukVpotDUg3uj32qTTLYxHc,7103
|
|
8
|
+
jx/utils.py,sha256=UDuyu3pIXirct71XbTClhMFeMaHr2ujmcQ6s2tKGBrY,232
|
|
9
|
+
jx-0.1.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
|
|
10
|
+
jx-0.1.0.dist-info/METADATA,sha256=DuJ03AieE3hsJMKN8SLtlIAeXqre4oypWAIvCA5CVa0,1674
|
|
11
|
+
jx-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
jx-0.1.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
|
|
13
|
+
jx-0.1.0.dist-info/RECORD,,
|
jx-0.1.0.dist-info/WHEEL
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Juan-Pablo Scaletti
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
jx
|