jx 0.1.0__py3-none-any.whl → 0.2.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/attrs.py +40 -27
- jx/catalog.py +170 -68
- jx/component.py +71 -27
- jx/meta.py +3 -2
- jx/parser.py +236 -40
- jx/utils.py +12 -0
- {jx-0.1.0.dist-info → jx-0.2.0.dist-info}/METADATA +37 -1
- jx-0.2.0.dist-info/RECORD +13 -0
- jx-0.1.0.dist-info/RECORD +0 -13
- {jx-0.1.0.dist-info → jx-0.2.0.dist-info}/WHEEL +0 -0
- {jx-0.1.0.dist-info → jx-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {jx-0.1.0.dist-info → jx-0.2.0.dist-info}/top_level.txt +0 -0
jx/attrs.py
CHANGED
|
@@ -30,14 +30,13 @@ def quote(text: str) -> str:
|
|
|
30
30
|
|
|
31
31
|
|
|
32
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
33
|
__slots__ = ("_seq",)
|
|
39
34
|
|
|
40
35
|
def __init__(self, seq):
|
|
36
|
+
"""
|
|
37
|
+
Behave like regular strings, but the actual casting of the initial value
|
|
38
|
+
is deferred until the value is actually required.
|
|
39
|
+
"""
|
|
41
40
|
self._seq = seq
|
|
42
41
|
|
|
43
42
|
@cached_property
|
|
@@ -46,21 +45,20 @@ class LazyString(UserString):
|
|
|
46
45
|
|
|
47
46
|
|
|
48
47
|
class Attrs:
|
|
49
|
-
""
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
For HTML classes you can use the name "classes" (instead of "class")
|
|
55
|
-
if you need to.
|
|
48
|
+
def __init__(self, attrs: "dict[str, t.Any| LazyString]") -> None:
|
|
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.
|
|
56
53
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
`attrs.render()` is invoked.
|
|
54
|
+
For HTML classes you can use the name "classes" (instead of "class")
|
|
55
|
+
if you need to.
|
|
60
56
|
|
|
61
|
-
|
|
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.
|
|
62
60
|
|
|
63
|
-
|
|
61
|
+
"""
|
|
64
62
|
attributes: "dict[str, str | LazyString]" = {}
|
|
65
63
|
properties: set[str] = set()
|
|
66
64
|
|
|
@@ -90,7 +88,7 @@ class Attrs:
|
|
|
90
88
|
Example:
|
|
91
89
|
|
|
92
90
|
```python
|
|
93
|
-
attrs =
|
|
91
|
+
attrs = Attrs({"class": "italic bold bg-blue wide abcde"})
|
|
94
92
|
attrs.set(class="bold text-white")
|
|
95
93
|
print(attrs.classes)
|
|
96
94
|
abcde bg-blue bold italic text-white wide
|
|
@@ -108,7 +106,7 @@ class Attrs:
|
|
|
108
106
|
Example:
|
|
109
107
|
|
|
110
108
|
```python
|
|
111
|
-
attrs =
|
|
109
|
+
attrs = Attrs({
|
|
112
110
|
"class": "lorem ipsum",
|
|
113
111
|
"data_test": True,
|
|
114
112
|
"hidden": True,
|
|
@@ -157,10 +155,12 @@ class Attrs:
|
|
|
157
155
|
- The underscores in the names will be translated automatically to dashes,
|
|
158
156
|
so `aria_selected` becomes the attribute `aria-selected`.
|
|
159
157
|
|
|
158
|
+
TODO: vue-style
|
|
159
|
+
|
|
160
160
|
Example:
|
|
161
161
|
|
|
162
162
|
```python
|
|
163
|
-
attrs =
|
|
163
|
+
attrs = Attrs({"secret": "qwertyuiop"})
|
|
164
164
|
attrs.set(secret=False)
|
|
165
165
|
attrs.as_dict
|
|
166
166
|
{}
|
|
@@ -169,7 +169,7 @@ class Attrs:
|
|
|
169
169
|
attrs.as_dict
|
|
170
170
|
{"count":42, "lorem":"ipsum", "data_good": True}
|
|
171
171
|
|
|
172
|
-
attrs =
|
|
172
|
+
attrs = Attrs({"class": "b c a"})
|
|
173
173
|
attrs.set(class="c b f d e")
|
|
174
174
|
attrs.as_dict
|
|
175
175
|
{"class": "a b c d e f"}
|
|
@@ -199,7 +199,7 @@ class Attrs:
|
|
|
199
199
|
Example:
|
|
200
200
|
|
|
201
201
|
```python
|
|
202
|
-
attrs =
|
|
202
|
+
attrs = Attrs({"lorem": "ipsum"})
|
|
203
203
|
attrs.setdefault(tabindex=0, lorem="meh")
|
|
204
204
|
attrs.as_dict
|
|
205
205
|
# "tabindex" changed but "lorem" didn't
|
|
@@ -222,10 +222,15 @@ class Attrs:
|
|
|
222
222
|
"""
|
|
223
223
|
Adds one or more classes to the list of classes, if not already present.
|
|
224
224
|
|
|
225
|
+
Arguments:
|
|
226
|
+
|
|
227
|
+
values:
|
|
228
|
+
One or more class names to add, separated by spaces.
|
|
229
|
+
|
|
225
230
|
Example:
|
|
226
231
|
|
|
227
232
|
```python
|
|
228
|
-
attrs =
|
|
233
|
+
attrs = Attrs({"class": "a b c"})
|
|
229
234
|
attrs.add_class("c", "d")
|
|
230
235
|
attrs.as_dict
|
|
231
236
|
{"class": "a b c d"}
|
|
@@ -243,7 +248,7 @@ class Attrs:
|
|
|
243
248
|
Example:
|
|
244
249
|
|
|
245
250
|
```python
|
|
246
|
-
attrs =
|
|
251
|
+
attrs = Attrs({"class": "a b c"})
|
|
247
252
|
attrs.remove_class("c", "d")
|
|
248
253
|
attrs.as_dict
|
|
249
254
|
{"class": "a b"}
|
|
@@ -258,10 +263,18 @@ class Attrs:
|
|
|
258
263
|
Returns the value of the attribute or property,
|
|
259
264
|
or the default value if it doesn't exists.
|
|
260
265
|
|
|
266
|
+
Arguments:
|
|
267
|
+
|
|
268
|
+
name:
|
|
269
|
+
The name of the attribute or property to get.
|
|
270
|
+
|
|
271
|
+
default:
|
|
272
|
+
The default value to return if the attribute or property doesn't exist.
|
|
273
|
+
|
|
261
274
|
Example:
|
|
262
275
|
|
|
263
276
|
```python
|
|
264
|
-
attrs =
|
|
277
|
+
attrs = Attrs({"lorem": "ipsum", "hidden": True})
|
|
265
278
|
|
|
266
279
|
attrs.get("lorem", defaut="bar")
|
|
267
280
|
'ipsum'
|
|
@@ -291,7 +304,7 @@ class Attrs:
|
|
|
291
304
|
Renders the attributes and properties as a string.
|
|
292
305
|
|
|
293
306
|
Any arguments you use with this function are merged with the existing
|
|
294
|
-
attibutes/properties by the same rules as the `
|
|
307
|
+
attibutes/properties by the same rules as the `Attrs.set()` function:
|
|
295
308
|
|
|
296
309
|
- Pass a name and a value to set an attribute (e.g. `type="text"`)
|
|
297
310
|
- Use `True` as a value to set a property (e.g. `disabled`)
|
|
@@ -308,7 +321,7 @@ class Attrs:
|
|
|
308
321
|
Example:
|
|
309
322
|
|
|
310
323
|
```python
|
|
311
|
-
attrs =
|
|
324
|
+
attrs = Attrs({"class": "ipsum", "data_good": True, "width": 42})
|
|
312
325
|
|
|
313
326
|
attrs.render()
|
|
314
327
|
'class="ipsum" width="42" data-good'
|
jx/catalog.py
CHANGED
|
@@ -24,32 +24,55 @@ class CData:
|
|
|
24
24
|
code: CodeType | None = None
|
|
25
25
|
required: tuple[str, ...] = ()
|
|
26
26
|
optional: dict[str, t.Any] = field(default_factory=dict) # { attr: default_value }
|
|
27
|
-
imports: dict[str, str] = field(default_factory=dict) # {
|
|
27
|
+
imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
|
|
28
28
|
css: tuple[str, ...] = ()
|
|
29
29
|
js: tuple[str, ...] = ()
|
|
30
|
+
slots: tuple[str, ...] = ()
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
class Catalog:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
Arguments:
|
|
37
|
-
"""
|
|
34
|
+
# IDEA: This dict could be replaced by a dict-like object
|
|
35
|
+
# that usesa LRU cache (to limit the memory used)
|
|
36
|
+
# or even a shared Redis/Memcache cache.
|
|
38
37
|
components: dict[str, CData]
|
|
39
38
|
|
|
40
39
|
def __init__(
|
|
41
40
|
self,
|
|
42
41
|
folder: str | Path | None = None,
|
|
43
42
|
*,
|
|
43
|
+
auto_reload: bool = True,
|
|
44
44
|
jinja_env: jinja2.Environment | None = None,
|
|
45
45
|
filters: dict[str, t.Any] | None = None,
|
|
46
46
|
tests: dict[str, t.Any] | None = None,
|
|
47
47
|
extensions: list | None = None,
|
|
48
|
-
auto_reload: bool = True,
|
|
49
48
|
**globals: t.Any,
|
|
50
49
|
) -> None:
|
|
50
|
+
"""
|
|
51
|
+
Manager of the components and their global settings.
|
|
52
|
+
|
|
53
|
+
Arguments:
|
|
54
|
+
folder:
|
|
55
|
+
Optional folder path to scan for components. It's a shortcut to
|
|
56
|
+
calling `add_folder` when only one is used.
|
|
57
|
+
auto_reload:
|
|
58
|
+
Whether to check the last-modified time of the components files and
|
|
59
|
+
automatically re-process them if they change. The performance impact of
|
|
60
|
+
leaving it on is minimal, but *might* be noticeable when rendering a page
|
|
61
|
+
that uses a large number of different components.
|
|
62
|
+
jinja_env:
|
|
63
|
+
Optional Jinja2 environment to use for rendering.
|
|
64
|
+
filters:
|
|
65
|
+
Optional extra Jinja2 filters to add to the environment.
|
|
66
|
+
extensions:
|
|
67
|
+
Optional extra Jinja2 extensions to add to the environment.
|
|
68
|
+
tests:
|
|
69
|
+
Optional extra Jinja2 tests to add to the environment.
|
|
70
|
+
**globals:
|
|
71
|
+
Variables to make available to all components by default.
|
|
72
|
+
|
|
73
|
+
"""
|
|
51
74
|
self.components = {}
|
|
52
|
-
self.jinja_env = self.
|
|
75
|
+
self.jinja_env = self._make_jinja_env(
|
|
53
76
|
jinja_env=jinja_env,
|
|
54
77
|
globals=globals,
|
|
55
78
|
filters=filters,
|
|
@@ -60,64 +83,22 @@ class Catalog:
|
|
|
60
83
|
if folder:
|
|
61
84
|
self.add_folder(folder)
|
|
62
85
|
|
|
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
86
|
def add_folder(
|
|
100
87
|
self,
|
|
101
88
|
path: str | Path,
|
|
102
89
|
*,
|
|
103
90
|
prefix: str = "",
|
|
91
|
+
preload: bool = True
|
|
104
92
|
) -> None:
|
|
105
93
|
"""
|
|
106
94
|
Add a folder path from which to search for components, optionally under a prefix.
|
|
107
95
|
|
|
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
96
|
Components without a prefix can be imported as a path relative to the folder,
|
|
118
97
|
e.g.: `sub/folder/component.jinja` or with a path relative to the component
|
|
119
98
|
where it is used: `./folder/component.jinja`.
|
|
120
99
|
|
|
100
|
+
Relative imports cannot go outside the folder.
|
|
101
|
+
|
|
121
102
|
Components added with a prefix must be imported using the prefix followed
|
|
122
103
|
by a colon: `prefix:sub/folder/component.jinja`. If the importing is
|
|
123
104
|
done from within a component with the prefix itself, a relative
|
|
@@ -128,6 +109,23 @@ class Catalog:
|
|
|
128
109
|
with a component with the same subpath/filename, the one in the folder
|
|
129
110
|
added **first** will be used and the other ignored.
|
|
130
111
|
|
|
112
|
+
WARNING: You cannot move or delete components files from the folder after
|
|
113
|
+
calling this method, but you can call it again to add new components added
|
|
114
|
+
to the folder. This is unrelated to the value of `preload`.
|
|
115
|
+
|
|
116
|
+
Arguments:
|
|
117
|
+
path:
|
|
118
|
+
Absolute path of the folder with component files.
|
|
119
|
+
prefix:
|
|
120
|
+
Optional path prefix that all the components in the folder
|
|
121
|
+
will have. The default is empty.
|
|
122
|
+
preload:
|
|
123
|
+
Whether to preload the data of components in the folder.
|
|
124
|
+
If set to `True` (the default), the component data will be loaded into
|
|
125
|
+
memory when the folder is added, instead of just before rendering it.
|
|
126
|
+
This makes the first render faster at the expense of a few
|
|
127
|
+
microseconds upfront.
|
|
128
|
+
|
|
131
129
|
"""
|
|
132
130
|
base_path = Path(path).resolve()
|
|
133
131
|
prefix = prefix.replace("\\", "/").strip("./@ ")
|
|
@@ -149,10 +147,57 @@ class Catalog:
|
|
|
149
147
|
)
|
|
150
148
|
self.components[relpath] = cdata
|
|
151
149
|
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
if preload:
|
|
151
|
+
for relpath in self.components:
|
|
152
|
+
self.components[relpath] = self.get_component_data(relpath)
|
|
153
|
+
|
|
154
|
+
def render(self, relpath: str, globals: dict[str, t.Any] | None = None, **kwargs) -> str:
|
|
155
|
+
"""
|
|
156
|
+
Render a component with the given relative path and context.
|
|
157
|
+
|
|
158
|
+
Arguments:
|
|
159
|
+
relpath:
|
|
160
|
+
The path of the component to render, including the extension,relative to its view folder.
|
|
161
|
+
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
162
|
+
globals:
|
|
163
|
+
Optional global variables to make available to the component and all its
|
|
164
|
+
imported components.
|
|
165
|
+
**kwargs:
|
|
166
|
+
Keyword arguments to pass to the component.
|
|
167
|
+
They will be available in the component's context but not to its imported components.
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
The rendered component as a string.
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
relpath = relpath.replace("\\", "/").strip("/")
|
|
174
|
+
co = self.get_component(relpath)
|
|
175
|
+
|
|
176
|
+
globals = globals or {}
|
|
177
|
+
globals.update({
|
|
178
|
+
"assets": {
|
|
179
|
+
"css": co.collect_css,
|
|
180
|
+
"js": co.collect_js,
|
|
181
|
+
"render_css": co.render_css,
|
|
182
|
+
"render_js": co.render_js,
|
|
183
|
+
"render": co.render_assets,
|
|
184
|
+
},
|
|
185
|
+
})
|
|
186
|
+
co.globals = globals
|
|
187
|
+
|
|
188
|
+
return co.render(**kwargs)
|
|
154
189
|
|
|
155
190
|
def get_component_data(self, relpath: str) -> CData:
|
|
191
|
+
"""
|
|
192
|
+
Get the component data from the cache, or load it from the file system
|
|
193
|
+
if needed.
|
|
194
|
+
|
|
195
|
+
Arguments:
|
|
196
|
+
relpath:
|
|
197
|
+
The path of the component to render, including the extension,relative to its view folder.
|
|
198
|
+
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
199
|
+
|
|
200
|
+
"""
|
|
156
201
|
cdata = self.components.get(relpath)
|
|
157
202
|
if not cdata:
|
|
158
203
|
raise ImportError(relpath)
|
|
@@ -173,7 +218,7 @@ class Catalog:
|
|
|
173
218
|
source=source,
|
|
174
219
|
components=list(meta.imports.keys())
|
|
175
220
|
)
|
|
176
|
-
parsed_source = parser.parse()
|
|
221
|
+
parsed_source, slots = parser.parse()
|
|
177
222
|
code = self.jinja_env.compile(
|
|
178
223
|
source=parsed_source,
|
|
179
224
|
name=relpath,
|
|
@@ -186,9 +231,19 @@ class Catalog:
|
|
|
186
231
|
cdata.imports = meta.imports
|
|
187
232
|
cdata.css = meta.css
|
|
188
233
|
cdata.js = meta.js
|
|
234
|
+
cdata.slots = slots
|
|
189
235
|
return cdata
|
|
190
236
|
|
|
191
237
|
def get_component(self, relpath: str) -> Component:
|
|
238
|
+
"""
|
|
239
|
+
Instantiate and return a component object by its relative path.
|
|
240
|
+
|
|
241
|
+
Arguments:
|
|
242
|
+
relpath:
|
|
243
|
+
The path of the component to render, including the extension,relative to its view folder.
|
|
244
|
+
e.g.: "sub/component.jinja". Always use the forward slash (/) as the path separator.
|
|
245
|
+
|
|
246
|
+
"""
|
|
192
247
|
cdata = self.get_component_data(relpath)
|
|
193
248
|
assert cdata.code is not None
|
|
194
249
|
tmpl = jinja2.Template.from_code(
|
|
@@ -203,25 +258,72 @@ class Catalog:
|
|
|
203
258
|
get_component=self.get_component,
|
|
204
259
|
required=cdata.required,
|
|
205
260
|
optional=cdata.optional,
|
|
261
|
+
imports=cdata.imports,
|
|
206
262
|
css=cdata.css,
|
|
207
263
|
js=cdata.js,
|
|
208
|
-
|
|
264
|
+
slots=cdata.slots,
|
|
209
265
|
)
|
|
210
266
|
return co
|
|
211
267
|
|
|
212
|
-
|
|
213
|
-
|
|
268
|
+
# Private
|
|
269
|
+
|
|
270
|
+
def _make_jinja_env(
|
|
271
|
+
self,
|
|
272
|
+
*,
|
|
273
|
+
jinja_env: jinja2.Environment | None = None,
|
|
274
|
+
globals: dict[str, t.Any] | None = None,
|
|
275
|
+
filters: dict[str, t.Any] | None = None,
|
|
276
|
+
tests: dict[str, t.Any] | None = None,
|
|
277
|
+
extensions: list | None = None,
|
|
278
|
+
) -> jinja2.Environment:
|
|
279
|
+
"""
|
|
280
|
+
Create a new Jinja2 environment with the specified settings.
|
|
281
|
+
|
|
282
|
+
If an existing environment is provided, an "overlay" of it will
|
|
283
|
+
be created and used.
|
|
284
|
+
|
|
285
|
+
Arguments:
|
|
286
|
+
jinja_env:
|
|
287
|
+
Optional Jinja2 environment to use as a base.
|
|
288
|
+
globals:
|
|
289
|
+
Optional global variables to add to the environment.
|
|
290
|
+
filters:
|
|
291
|
+
Optional extra Jinja2 filters to add to the environment.
|
|
292
|
+
extensions:
|
|
293
|
+
Optional extra Jinja2 extensions to add to the environment.
|
|
294
|
+
tests:
|
|
295
|
+
Optional extra Jinja2 tests to add to the environment.
|
|
296
|
+
|
|
297
|
+
"""
|
|
298
|
+
jinja_env = jinja_env or getattr(self, "jinja_env", None)
|
|
299
|
+
if jinja_env:
|
|
300
|
+
env = jinja_env.overlay()
|
|
301
|
+
else:
|
|
302
|
+
env = jinja2.Environment()
|
|
214
303
|
|
|
215
304
|
globals = globals or {}
|
|
216
305
|
globals.update({
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"render_css": co.render_css,
|
|
221
|
-
"render_js": co.render_js,
|
|
222
|
-
"render": co.render_assets,
|
|
223
|
-
},
|
|
306
|
+
# A unique ID generator for HTML elements, see `utils.get_random_id`
|
|
307
|
+
# docstring for more information.
|
|
308
|
+
"_get_random_id": utils.get_random_id,
|
|
224
309
|
})
|
|
225
|
-
|
|
310
|
+
env.globals.update(globals)
|
|
226
311
|
|
|
227
|
-
|
|
312
|
+
filters = filters or {}
|
|
313
|
+
env.filters.update(filters)
|
|
314
|
+
|
|
315
|
+
tests = tests or {}
|
|
316
|
+
env.tests.update(tests)
|
|
317
|
+
|
|
318
|
+
extensions = extensions or []
|
|
319
|
+
# The "jinja2.ext.do" extension allows the use of the "do" statement in templates,
|
|
320
|
+
# that execute statements without outputting a value.
|
|
321
|
+
# Is specially useful for manipulating the `attrs` object.
|
|
322
|
+
extensions.extend(["jinja2.ext.do"])
|
|
323
|
+
for ext in extensions:
|
|
324
|
+
env.add_extension(ext)
|
|
325
|
+
|
|
326
|
+
env.autoescape = True
|
|
327
|
+
env.undefined = jinja2.StrictUndefined
|
|
328
|
+
|
|
329
|
+
return env
|
jx/component.py
CHANGED
|
@@ -22,9 +22,10 @@ class Component:
|
|
|
22
22
|
"get_component",
|
|
23
23
|
"required",
|
|
24
24
|
"optional",
|
|
25
|
+
"imports",
|
|
25
26
|
"css",
|
|
26
27
|
"js",
|
|
27
|
-
"
|
|
28
|
+
"slots",
|
|
28
29
|
"globals",
|
|
29
30
|
)
|
|
30
31
|
|
|
@@ -36,19 +37,45 @@ class Component:
|
|
|
36
37
|
get_component: Callable[[str], "Component"],
|
|
37
38
|
required: tuple[str, ...] = (),
|
|
38
39
|
optional: dict[str, t.Any] | None = None,
|
|
40
|
+
imports: dict[str, str] | None = None,
|
|
39
41
|
css: tuple[str, ...] = (),
|
|
40
42
|
js: tuple[str, ...] = (),
|
|
41
|
-
|
|
43
|
+
slots: tuple[str, ...] = (),
|
|
42
44
|
) -> None:
|
|
45
|
+
"""
|
|
46
|
+
Internal object that represents a Jx component.
|
|
47
|
+
|
|
48
|
+
Arguments:
|
|
49
|
+
relpath:
|
|
50
|
+
The "name" of the component.
|
|
51
|
+
tmpl:
|
|
52
|
+
The jinja2.Template for the component.
|
|
53
|
+
get_component:
|
|
54
|
+
A callable that retrieves a component by its name/relpath.
|
|
55
|
+
required:
|
|
56
|
+
A tuple of required attribute names.
|
|
57
|
+
optional:
|
|
58
|
+
A dictionary of optional attributes and their default values.
|
|
59
|
+
imports:
|
|
60
|
+
A dictionary of imported component names as "name": "relpath" pairs.
|
|
61
|
+
css:
|
|
62
|
+
A tuple of CSS file URLs.
|
|
63
|
+
js:
|
|
64
|
+
A tuple of JS file URLs.
|
|
65
|
+
slots:
|
|
66
|
+
A tuple of slot names.
|
|
67
|
+
|
|
68
|
+
"""
|
|
43
69
|
self.relpath = relpath
|
|
44
70
|
self.tmpl = tmpl
|
|
45
71
|
self.get_component = get_component
|
|
46
72
|
|
|
47
73
|
self.required = required
|
|
48
74
|
self.optional = optional or {}
|
|
75
|
+
self.imports = imports or {}
|
|
49
76
|
self.css = css
|
|
50
77
|
self.js = js
|
|
51
|
-
self.
|
|
78
|
+
self.slots = slots
|
|
52
79
|
|
|
53
80
|
self.globals: dict[str, t.Any] = {}
|
|
54
81
|
|
|
@@ -57,10 +84,10 @@ class Component:
|
|
|
57
84
|
*,
|
|
58
85
|
content: str | None = None,
|
|
59
86
|
attrs: Attrs | dict[str, t.Any] | None = None,
|
|
60
|
-
caller: Callable[[], str] | None = None,
|
|
87
|
+
caller: Callable[[str], str] | None = None,
|
|
61
88
|
**params: t.Any
|
|
62
89
|
) -> Markup:
|
|
63
|
-
content = content if content is not None else caller() if caller else ""
|
|
90
|
+
content = content if content is not None else caller("") if caller else ""
|
|
64
91
|
attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
|
|
65
92
|
params = {**attrs, **params}
|
|
66
93
|
props, attrs = self.filter_attrs(params)
|
|
@@ -69,6 +96,14 @@ class Component:
|
|
|
69
96
|
globals.setdefault("attrs", Attrs(attrs))
|
|
70
97
|
globals.setdefault("content", content)
|
|
71
98
|
|
|
99
|
+
slots = {}
|
|
100
|
+
if caller:
|
|
101
|
+
for name in self.slots:
|
|
102
|
+
body = caller(name)
|
|
103
|
+
if body != content:
|
|
104
|
+
slots[name] = body
|
|
105
|
+
props["_slots"] = slots
|
|
106
|
+
|
|
72
107
|
html = self.tmpl.render({**props, **globals}).lstrip()
|
|
73
108
|
return Markup(html)
|
|
74
109
|
|
|
@@ -93,41 +128,41 @@ class Component:
|
|
|
93
128
|
child.globals = self.globals
|
|
94
129
|
return child
|
|
95
130
|
|
|
96
|
-
def collect_css(self,
|
|
131
|
+
def collect_css(self, _visited: set[str] | None = None) -> list[str]:
|
|
97
132
|
"""
|
|
98
133
|
Returns a list of CSS files for the component and its children.
|
|
99
134
|
"""
|
|
100
135
|
urls = dict.fromkeys(self.css, 1)
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
_visited = _visited or set()
|
|
137
|
+
_visited.add(self.relpath)
|
|
103
138
|
|
|
104
139
|
for name, relpath in self.imports.items():
|
|
105
|
-
if relpath in
|
|
140
|
+
if relpath in _visited:
|
|
106
141
|
continue
|
|
107
142
|
co = self.get_child(name)
|
|
108
|
-
for file in co.collect_css(
|
|
143
|
+
for file in co.collect_css(_visited=_visited):
|
|
109
144
|
if file not in urls:
|
|
110
145
|
urls[file] = 1
|
|
111
|
-
|
|
146
|
+
_visited.add(relpath)
|
|
112
147
|
|
|
113
148
|
return list(urls.keys())
|
|
114
149
|
|
|
115
|
-
def collect_js(self,
|
|
150
|
+
def collect_js(self, _visited: set[str] | None = None) -> list[str]:
|
|
116
151
|
"""
|
|
117
152
|
Returns a list of JS files for the component and its children.
|
|
118
153
|
"""
|
|
119
154
|
urls = dict.fromkeys(self.js, 1)
|
|
120
|
-
|
|
121
|
-
|
|
155
|
+
_visited = _visited or set()
|
|
156
|
+
_visited.add(self.relpath)
|
|
122
157
|
|
|
123
158
|
for name, relpath in self.imports.items():
|
|
124
|
-
if relpath in
|
|
159
|
+
if relpath in _visited:
|
|
125
160
|
continue
|
|
126
161
|
co = self.get_child(name)
|
|
127
|
-
for file in co.collect_js(
|
|
162
|
+
for file in co.collect_js(_visited=_visited):
|
|
128
163
|
if file not in urls:
|
|
129
164
|
urls[file] = 1
|
|
130
|
-
|
|
165
|
+
_visited.add(relpath)
|
|
131
166
|
|
|
132
167
|
return list(urls.keys())
|
|
133
168
|
|
|
@@ -135,10 +170,6 @@ class Component:
|
|
|
135
170
|
"""
|
|
136
171
|
Uses the `collect_css()` list to generate an HTML fragment
|
|
137
172
|
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
173
|
"""
|
|
143
174
|
html = []
|
|
144
175
|
for url in self.collect_css():
|
|
@@ -151,9 +182,15 @@ class Component:
|
|
|
151
182
|
Uses the `collected_js()` list to generate an HTML fragment
|
|
152
183
|
with `<script type="module" src="{url}"></script>` tags.
|
|
153
184
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
185
|
+
Arguments:
|
|
186
|
+
module:
|
|
187
|
+
Whether to render the script tags as modules, e.g.:
|
|
188
|
+
`<script type="module" src="..."></script>`
|
|
189
|
+
defer:
|
|
190
|
+
Whether to add the `defer` attribute to the script tags,
|
|
191
|
+
if `module` is `False` (all module scripts are also deferred), e.g.:
|
|
192
|
+
`<script src="..." defer></script>`
|
|
193
|
+
|
|
157
194
|
"""
|
|
158
195
|
html = []
|
|
159
196
|
for url in self.collect_js():
|
|
@@ -172,9 +209,16 @@ class Component:
|
|
|
172
209
|
Calls `render_css()` and `render_js()` to generate
|
|
173
210
|
an HTML fragment with `<link rel="stylesheet" href="{url}">`
|
|
174
211
|
and `<script type="module" src="{url}"></script>` tags.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
212
|
+
|
|
213
|
+
Arguments:
|
|
214
|
+
module:
|
|
215
|
+
Whether to render the script tags as modules, e.g.:
|
|
216
|
+
`<script type="module" src="..."></script>`
|
|
217
|
+
defer:
|
|
218
|
+
Whether to add the `defer` attribute to the script tags,
|
|
219
|
+
if `module` is `False` (all module scripts are also deferred), e.g.:
|
|
220
|
+
`<script src="..." defer></script>`
|
|
221
|
+
|
|
178
222
|
"""
|
|
179
223
|
html_css = self.render_css()
|
|
180
224
|
html_js = self.render_js()
|
jx/meta.py
CHANGED
|
@@ -61,7 +61,8 @@ def extract_metadata(source: str, base_path: Path, fullpath: Path) -> Meta:
|
|
|
61
61
|
The absolute full path of the current template.
|
|
62
62
|
|
|
63
63
|
Returns:
|
|
64
|
-
A Meta object containing the extracted metadata.
|
|
64
|
+
A `Meta` object containing the extracted metadata.
|
|
65
|
+
|
|
65
66
|
"""
|
|
66
67
|
meta = Meta()
|
|
67
68
|
|
|
@@ -138,7 +139,7 @@ def parse_args_expr(expr: str) -> tuple[tuple[str, ...], dict[str, t.Any]]:
|
|
|
138
139
|
return tuple(required), optional
|
|
139
140
|
|
|
140
141
|
|
|
141
|
-
def eval_expression(input_string):
|
|
142
|
+
def eval_expression(input_string: str) -> t.Any:
|
|
142
143
|
code = compile(input_string, "<string>", "eval")
|
|
143
144
|
for name in code.co_names:
|
|
144
145
|
if name not in ALLOWED_NAMES_IN_EXPRESSION_VALUES:
|
jx/parser.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
3
3
|
"""
|
|
4
|
+
|
|
4
5
|
import re
|
|
5
6
|
import typing as t
|
|
6
7
|
from uuid import uuid4
|
|
@@ -11,7 +12,7 @@ from .exceptions import TemplateSyntaxError
|
|
|
11
12
|
from .utils import logger
|
|
12
13
|
|
|
13
14
|
|
|
14
|
-
BLOCK_CALL = '{% call _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
|
|
15
|
+
BLOCK_CALL = '{% call(_slot="") _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
|
|
15
16
|
INLINE_CALL = '{{ _get("[TAG]").render([ATTRS]) }}'
|
|
16
17
|
|
|
17
18
|
re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
|
|
@@ -32,6 +33,17 @@ re_attr = r"""
|
|
|
32
33
|
"""
|
|
33
34
|
RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
|
|
34
35
|
|
|
36
|
+
RE_LSTRIP = r"\s*(?P<lstrip>-?)%}"
|
|
37
|
+
RE_RSTRIP = r"{%(?P<rstrip>-?)\s*"
|
|
38
|
+
|
|
39
|
+
RE_SLOT_OPEN = r"{%-?\s*slot\s+(?P<name>[0-9A-Za-z_.:$-]+)" + RE_LSTRIP
|
|
40
|
+
RE_SLOT_CLOSE = RE_RSTRIP + r"endslot\s*-?%}"
|
|
41
|
+
RX_SLOT = re.compile(rf"{RE_SLOT_OPEN}(?P<default>.*?)({RE_SLOT_CLOSE})", re.DOTALL)
|
|
42
|
+
|
|
43
|
+
RE_FILL_OPEN = r"{%-?\s*fill\s+(?P<name>[0-9A-Za-z_.:$-]+)" + RE_LSTRIP
|
|
44
|
+
RE_FILL_CLOSE = RE_RSTRIP + r"endfill\s*-?%}"
|
|
45
|
+
RX_FILL = re.compile(rf"{RE_FILL_OPEN}(?P<body>.*?)({RE_FILL_CLOSE})", re.DOTALL)
|
|
46
|
+
|
|
35
47
|
|
|
36
48
|
def escape(s: t.Any, /) -> Markup:
|
|
37
49
|
return Markup(
|
|
@@ -45,20 +57,66 @@ def escape(s: t.Any, /) -> Markup:
|
|
|
45
57
|
|
|
46
58
|
|
|
47
59
|
class JxParser:
|
|
48
|
-
def __init__(
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
name: str,
|
|
64
|
+
source: str,
|
|
65
|
+
components: list[str],
|
|
66
|
+
):
|
|
67
|
+
"""
|
|
68
|
+
A parser that transforms a template's source code by replacing
|
|
69
|
+
TitledCased HTML tags with their corresponding component calls.
|
|
70
|
+
|
|
71
|
+
Only the names defined in the `components` list are allowed.
|
|
72
|
+
|
|
73
|
+
Arguments:
|
|
74
|
+
name:
|
|
75
|
+
The name of the template for error reporting.
|
|
76
|
+
source:
|
|
77
|
+
The source code of the template.
|
|
78
|
+
components:
|
|
79
|
+
A list of allowed component names.
|
|
80
|
+
|
|
81
|
+
"""
|
|
49
82
|
self.name = name
|
|
50
83
|
self.source = source
|
|
51
84
|
self.components = components
|
|
52
85
|
|
|
53
|
-
def parse(self, *, validate_tags: bool = True) -> str:
|
|
86
|
+
def parse(self, *, validate_tags: bool = True) -> tuple[str, tuple[str, ...]]:
|
|
87
|
+
"""
|
|
88
|
+
Parses the template source code.
|
|
89
|
+
|
|
90
|
+
Arguments:
|
|
91
|
+
validate_tags:
|
|
92
|
+
Whether to raise an error for unknown TitleCased tags.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
- The transformed template source code
|
|
96
|
+
- The list of slot names.
|
|
97
|
+
|
|
98
|
+
Raises:
|
|
99
|
+
TemplateSyntaxError:
|
|
100
|
+
If the template contains unknown components or syntax errors.
|
|
101
|
+
|
|
102
|
+
"""
|
|
54
103
|
raw_blocks = {}
|
|
55
104
|
source = self.source
|
|
56
105
|
source, raw_blocks = self.replace_raw_blocks(source)
|
|
57
106
|
source = self.process_tags(source, validate_tags=validate_tags)
|
|
107
|
+
source, slots = self.process_slots(source)
|
|
58
108
|
source = self.restore_raw_blocks(source, raw_blocks)
|
|
59
|
-
return source
|
|
109
|
+
return source, slots
|
|
60
110
|
|
|
61
111
|
def replace_raw_blocks(self, source: str) -> tuple[str, dict[str, str]]:
|
|
112
|
+
"""
|
|
113
|
+
Replace the `{% raw %}` blocks with temporary placeholders.
|
|
114
|
+
|
|
115
|
+
Arguments:
|
|
116
|
+
source:
|
|
117
|
+
The template source code.
|
|
118
|
+
|
|
119
|
+
"""
|
|
62
120
|
raw_blocks = {}
|
|
63
121
|
while True:
|
|
64
122
|
match = RX_RAW.search(source)
|
|
@@ -73,11 +131,32 @@ class JxParser:
|
|
|
73
131
|
return source, raw_blocks
|
|
74
132
|
|
|
75
133
|
def restore_raw_blocks(self, source: str, raw_blocks: dict[str, str]) -> str:
|
|
134
|
+
"""
|
|
135
|
+
Restores the original `{% raw %}` blocks from the temporary placeholders.
|
|
136
|
+
|
|
137
|
+
Arguments:
|
|
138
|
+
source:
|
|
139
|
+
The template source code.
|
|
140
|
+
raw_blocks:
|
|
141
|
+
A dictionary mapping placeholder keys to their original raw block content.
|
|
142
|
+
|
|
143
|
+
"""
|
|
76
144
|
for uid, code in raw_blocks.items():
|
|
77
145
|
source = source.replace(uid, code)
|
|
78
146
|
return source
|
|
79
147
|
|
|
80
148
|
def process_tags(self, source: str, *, validate_tags: bool = True) -> str:
|
|
149
|
+
"""
|
|
150
|
+
Search for TitledCased HTML tags in the template source code and replace
|
|
151
|
+
them with their corresponding component calls.
|
|
152
|
+
|
|
153
|
+
Arguments:
|
|
154
|
+
source:
|
|
155
|
+
The template source code.
|
|
156
|
+
validate_tags:
|
|
157
|
+
Whether to raise an error for unknown TitleCased tags.
|
|
158
|
+
|
|
159
|
+
"""
|
|
81
160
|
while True:
|
|
82
161
|
match = RX_TAG_NAME.search(source)
|
|
83
162
|
if not match:
|
|
@@ -85,21 +164,43 @@ class JxParser:
|
|
|
85
164
|
source = self.replace_tag(source, match, validate_tags=validate_tags)
|
|
86
165
|
return source
|
|
87
166
|
|
|
88
|
-
def replace_tag(
|
|
167
|
+
def replace_tag(
|
|
168
|
+
self,
|
|
169
|
+
source: str,
|
|
170
|
+
match: re.Match,
|
|
171
|
+
*,
|
|
172
|
+
validate_tags: bool = True,
|
|
173
|
+
) -> str:
|
|
174
|
+
"""
|
|
175
|
+
Replaces a single TitledCased HTML tag with its corresponding component call.
|
|
176
|
+
|
|
177
|
+
Arguments:
|
|
178
|
+
source:
|
|
179
|
+
The template source code.
|
|
180
|
+
match:
|
|
181
|
+
The regex match object for the tag.
|
|
182
|
+
validate_tags:
|
|
183
|
+
Whether to raise an error for unknown TitleCased tags.
|
|
184
|
+
|
|
185
|
+
"""
|
|
89
186
|
start, curr = match.span(0)
|
|
90
187
|
lineno = source[:start].count("\n") + 1
|
|
91
188
|
|
|
92
189
|
tag = match.group("tag")
|
|
93
190
|
if validate_tags and tag not in self.components:
|
|
94
191
|
line = self.source.split("\n")[lineno - 1]
|
|
95
|
-
raise TemplateSyntaxError(
|
|
192
|
+
raise TemplateSyntaxError(
|
|
193
|
+
f"[{self.name}:{lineno}] Unknown component `{tag}`\n{line}"
|
|
194
|
+
)
|
|
96
195
|
|
|
97
|
-
|
|
196
|
+
raw_attrs, end = self._parse_opening_tag(source, lineno=lineno, start=curr - 1)
|
|
98
197
|
if end == -1:
|
|
99
198
|
line = self.source.split("\n")[lineno - 1]
|
|
100
|
-
raise TemplateSyntaxError(
|
|
199
|
+
raise TemplateSyntaxError(
|
|
200
|
+
f"[{self.name}:{lineno}] Syntax error: `{tag}`\n{line}"
|
|
201
|
+
)
|
|
101
202
|
|
|
102
|
-
inline = source[end - 2:end] == "/>"
|
|
203
|
+
inline = source[end - 2 : end] == "/>"
|
|
103
204
|
if inline:
|
|
104
205
|
content = ""
|
|
105
206
|
else:
|
|
@@ -107,39 +208,135 @@ class JxParser:
|
|
|
107
208
|
index = source.find(close_tag, end, None)
|
|
108
209
|
if index == -1:
|
|
109
210
|
line = self.source.split("\n")[lineno - 1]
|
|
110
|
-
raise TemplateSyntaxError(
|
|
211
|
+
raise TemplateSyntaxError(
|
|
212
|
+
f"[{self.name}:{lineno}] Unclosed component `{tag}`\n{line}"
|
|
213
|
+
)
|
|
111
214
|
|
|
112
215
|
content = source[end:index]
|
|
113
216
|
end = index + len(close_tag)
|
|
114
217
|
|
|
115
|
-
|
|
116
|
-
|
|
218
|
+
if content:
|
|
219
|
+
content = self.process_fills(content)
|
|
117
220
|
|
|
221
|
+
attrs = self._parse_attrs(raw_attrs)
|
|
222
|
+
repl = self._build_call(tag, attrs, content)
|
|
118
223
|
return f"{source[:start]}{repl}{source[end:]}"
|
|
119
224
|
|
|
120
|
-
def
|
|
225
|
+
def process_slots(self, source: str) -> tuple[str, tuple[str, ...]]:
|
|
226
|
+
"""
|
|
227
|
+
Extracts slot content from the template source code.
|
|
228
|
+
|
|
229
|
+
Arguments:
|
|
230
|
+
source:
|
|
231
|
+
The template source code
|
|
232
|
+
|
|
233
|
+
Returns:
|
|
234
|
+
- The transformed template source code
|
|
235
|
+
- The list of slot names.
|
|
236
|
+
|
|
237
|
+
"""
|
|
238
|
+
slots = {}
|
|
239
|
+
while True:
|
|
240
|
+
match = RX_SLOT.search(source)
|
|
241
|
+
if not match:
|
|
242
|
+
break
|
|
243
|
+
start, end = match.span(0)
|
|
244
|
+
slot_name = match.group("name")
|
|
245
|
+
slot_default = match.group("default") or ""
|
|
246
|
+
lstrip = match.group("lstrip") == "-"
|
|
247
|
+
rstrip = match.group("rstrip") == "-"
|
|
248
|
+
if lstrip:
|
|
249
|
+
slot_default = slot_default.lstrip()
|
|
250
|
+
if rstrip:
|
|
251
|
+
slot_default = slot_default.rstrip()
|
|
252
|
+
|
|
253
|
+
slot_expr = "".join([
|
|
254
|
+
"{% if _slots.get('", slot_name,
|
|
255
|
+
"') %}{{ _slots['", slot_name,
|
|
256
|
+
"'] }}{% else %}", slot_default,
|
|
257
|
+
"{% endif %}"
|
|
258
|
+
])
|
|
259
|
+
source = f"{source[:start]}{slot_expr}{source[end:]}"
|
|
260
|
+
slots[slot_name] = 1
|
|
261
|
+
|
|
262
|
+
return source, tuple(slots.keys())
|
|
263
|
+
|
|
264
|
+
def process_fills(self, source: str) -> str:
|
|
265
|
+
"""
|
|
266
|
+
Processes `{% fill slot_name %}...{% endfill %}` blocks in the template source code.
|
|
267
|
+
|
|
268
|
+
Arguments:
|
|
269
|
+
source:
|
|
270
|
+
The template source code.
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
The modified source code prepended by fill contents as `if` statements.
|
|
274
|
+
|
|
275
|
+
"""
|
|
276
|
+
fills = {}
|
|
277
|
+
|
|
278
|
+
while True:
|
|
279
|
+
match = RX_FILL.search(source)
|
|
280
|
+
if not match:
|
|
281
|
+
break
|
|
282
|
+
start, end = match.span(0)
|
|
283
|
+
fill_name = match.group("name")
|
|
284
|
+
fill_body = match.group("body") or ""
|
|
285
|
+
lstrip = match.group("lstrip") == "-"
|
|
286
|
+
rstrip = match.group("rstrip") == "-"
|
|
287
|
+
if lstrip:
|
|
288
|
+
fill_body = fill_body.lstrip()
|
|
289
|
+
if rstrip:
|
|
290
|
+
fill_body = fill_body.rstrip()
|
|
291
|
+
fills[fill_name] = fill_body
|
|
292
|
+
source = f"{source[:start]}{source[end:]}"
|
|
293
|
+
|
|
294
|
+
if not fills:
|
|
295
|
+
return source
|
|
296
|
+
|
|
297
|
+
ifs = []
|
|
298
|
+
for fill_name, fill_body in fills.items():
|
|
299
|
+
ifs.append(f"{{% elif _slot == '{fill_name}' %}}{fill_body}")
|
|
300
|
+
# Replace the first occurrence of "elif" with "if"
|
|
301
|
+
str_ifs = f"\n{{% {''.join(ifs)[5:]}"
|
|
302
|
+
|
|
303
|
+
return f"{str_ifs}{{% else -%}}\n{source.strip()}\n{{%- endif %}}\n"
|
|
304
|
+
|
|
305
|
+
# Private
|
|
306
|
+
|
|
307
|
+
def _parse_opening_tag(
|
|
308
|
+
self, source: str, *, lineno: int, start: int
|
|
309
|
+
) -> tuple[str, int]:
|
|
310
|
+
"""
|
|
311
|
+
Parses the opening tag and returns the raw attributes and the position
|
|
312
|
+
where the opening tag ends.
|
|
313
|
+
"""
|
|
121
314
|
eof = len(source)
|
|
122
|
-
in_single_quotes = in_double_quotes = in_braces = False
|
|
315
|
+
in_single_quotes = in_double_quotes = in_braces = False
|
|
123
316
|
i = start
|
|
124
317
|
end = -1
|
|
125
318
|
|
|
126
319
|
while i < eof:
|
|
127
320
|
ch = source[i]
|
|
128
|
-
ch2 = source[i:i + 2]
|
|
321
|
+
ch2 = source[i : i + 2]
|
|
129
322
|
# print(ch, ch2, in_single_quotes, in_double_quotes, in_braces)
|
|
130
323
|
|
|
131
324
|
# Detects {{ … }} only when NOT inside quotes
|
|
132
325
|
if not in_single_quotes and not in_double_quotes:
|
|
133
326
|
if ch2 == "{{":
|
|
134
327
|
if in_braces:
|
|
135
|
-
raise TemplateSyntaxError(
|
|
328
|
+
raise TemplateSyntaxError(
|
|
329
|
+
f"[{self.name}:{lineno}] Unmatched braces"
|
|
330
|
+
)
|
|
136
331
|
in_braces = True
|
|
137
332
|
i += 2
|
|
138
333
|
continue
|
|
139
334
|
|
|
140
335
|
if ch2 == "}}":
|
|
141
336
|
if not in_braces:
|
|
142
|
-
raise TemplateSyntaxError(
|
|
337
|
+
raise TemplateSyntaxError(
|
|
338
|
+
f"[{self.name}:{lineno}] Unmatched braces"
|
|
339
|
+
)
|
|
143
340
|
in_braces = False
|
|
144
341
|
i += 2
|
|
145
342
|
continue
|
|
@@ -164,33 +361,25 @@ class JxParser:
|
|
|
164
361
|
attrs = source[start:end].strip().removesuffix("/>").removesuffix(">")
|
|
165
362
|
return attrs, end
|
|
166
363
|
|
|
167
|
-
def _parse_attrs(self,
|
|
168
|
-
|
|
169
|
-
|
|
364
|
+
def _parse_attrs(self, raw_attrs: str) -> list[str]:
|
|
365
|
+
"""
|
|
366
|
+
Parses the HTML attributes string and returns a list of '"key":value'
|
|
367
|
+
strings to be used in a components call.
|
|
368
|
+
"""
|
|
369
|
+
raw_attrs = raw_attrs.replace("\n", " ").strip()
|
|
370
|
+
if not raw_attrs:
|
|
170
371
|
return []
|
|
171
|
-
return RX_ATTR.findall(attrs)
|
|
172
372
|
|
|
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
373
|
attrs = []
|
|
181
|
-
for name, value in
|
|
374
|
+
for name, value in RX_ATTR.findall(raw_attrs):
|
|
182
375
|
name = name.strip().replace("-", "_")
|
|
183
376
|
value = value.strip()
|
|
184
377
|
|
|
185
378
|
if not value:
|
|
186
|
-
attrs.append(f'"{name}"
|
|
379
|
+
attrs.append(f'"{name}":True')
|
|
187
380
|
else:
|
|
188
|
-
# vue-like syntax
|
|
189
|
-
# if (
|
|
190
|
-
# name[0] == ":"
|
|
191
|
-
# and value[0] in ("\"'")
|
|
192
|
-
# and value[-1] in ("\"'")
|
|
193
|
-
# ):
|
|
381
|
+
# vue-like syntax could be possible
|
|
382
|
+
# if (name[0] == ":" and value[0] in ("\"'") and value[-1] in ("\"'")):
|
|
194
383
|
# value = value[1:-1].strip()
|
|
195
384
|
# name = name.lstrip(":")
|
|
196
385
|
|
|
@@ -198,16 +387,23 @@ class JxParser:
|
|
|
198
387
|
if value[:2] == "{{" and value[-2:] == "}}":
|
|
199
388
|
value = value[2:-2].strip()
|
|
200
389
|
|
|
201
|
-
attrs.append(f'"{name}"
|
|
390
|
+
attrs.append(f'"{name}":{value}')
|
|
391
|
+
|
|
392
|
+
return attrs
|
|
393
|
+
|
|
394
|
+
def _build_call(self, tag: str, attrs: list[str], content: str = "") -> str:
|
|
395
|
+
"""
|
|
396
|
+
Builds a component call string.
|
|
397
|
+
"""
|
|
398
|
+
logger.debug(f"{tag} {attrs} {'inline' if not content else ''}")
|
|
202
399
|
|
|
203
400
|
str_attrs = ""
|
|
204
401
|
if attrs:
|
|
205
|
-
str_attrs = "**{" + ", ".join(
|
|
402
|
+
str_attrs = "**{" + ", ".join(attrs) + "}"
|
|
206
403
|
|
|
207
404
|
if content:
|
|
208
405
|
return (
|
|
209
|
-
BLOCK_CALL
|
|
210
|
-
.replace("[TAG]", tag)
|
|
406
|
+
BLOCK_CALL.replace("[TAG]", tag)
|
|
211
407
|
.replace("[ATTRS]", str_attrs)
|
|
212
408
|
.replace("[CONTENT]", content)
|
|
213
409
|
)
|
jx/utils.py
CHANGED
|
@@ -9,5 +9,17 @@ logger = logging.getLogger("jx")
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
def get_random_id(prefix: str = "id") -> str:
|
|
12
|
+
"""
|
|
13
|
+
Returns an unique string suitable to be used for HTML element IDs.
|
|
14
|
+
|
|
15
|
+
HTML form elements, popovers, and other components require unique IDs
|
|
16
|
+
to function correctly. When you are writing custom components, this function
|
|
17
|
+
can be used to generate default IDs for such elements, so you don't have to
|
|
18
|
+
make it a required argument.
|
|
19
|
+
|
|
20
|
+
Arguments:
|
|
21
|
+
prefix: The prefix to use for the ID. Defaults to "id".
|
|
22
|
+
|
|
23
|
+
"""
|
|
12
24
|
return f"{prefix}-{str(uuid.uuid4().hex)}"
|
|
13
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: jx
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Replace your HTML templates with Python server-Side components
|
|
5
5
|
Author-email: Juan Pablo Scaletti <juanpablo@jpscaletti.com>
|
|
6
6
|
Project-URL: homepage, https://jx.scaletti.dev/
|
|
@@ -24,6 +24,7 @@ Description-Content-Type: text/markdown
|
|
|
24
24
|
License-File: LICENSE
|
|
25
25
|
Requires-Dist: jinja2>=3.0
|
|
26
26
|
Requires-Dist: markupsafe>=2.0
|
|
27
|
+
Requires-Dist: ty>=0.0.1a15
|
|
27
28
|
Dynamic: license-file
|
|
28
29
|
|
|
29
30
|
<h1>
|
|
@@ -40,3 +41,38 @@ Super components powers for your Jinja templates.
|
|
|
40
41
|
From chaos to clarity: The power of components in your server-side-rendered Python web app.
|
|
41
42
|
|
|
42
43
|
<!-- Documentation: https://jx.scaletti.dev/ -->
|
|
44
|
+
|
|
45
|
+
## How It Works
|
|
46
|
+
|
|
47
|
+
Jx is a Python library for creating reusable template components with Jinja2. It works by pre-parsing the template source and replacing TitleCased HTML tags with Jinja calls that render the component.
|
|
48
|
+
|
|
49
|
+
### Component Definition
|
|
50
|
+
|
|
51
|
+
Components are defined as regular Jinja2 templates (.jinja files) with special metadata comments:
|
|
52
|
+
|
|
53
|
+
- `{# def parameter1 parameter2=default_value #}` - Defines required and optional parameters
|
|
54
|
+
- `{# import "path/to/component.jinja" as ComponentName #}` - Imports other components
|
|
55
|
+
- `{# css "/path/to/style.css" #}` - Includes CSS files
|
|
56
|
+
- `{# js "/path/to/script.js" #}` - Includes JavaScript files
|
|
57
|
+
|
|
58
|
+
Example component:
|
|
59
|
+
|
|
60
|
+
```jinja
|
|
61
|
+
{# def message #}
|
|
62
|
+
{# import "button.jinja" as Button #}
|
|
63
|
+
|
|
64
|
+
<div class="greeting">{{ message }}</div>
|
|
65
|
+
<Button text="OK" />
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### Usage Example
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from jx import Catalog
|
|
72
|
+
|
|
73
|
+
# Create a catalog and add a components folder
|
|
74
|
+
catalog = Catalog("templates/components")
|
|
75
|
+
|
|
76
|
+
# Render a component with parameters
|
|
77
|
+
html = catalog.render("card.jinja", title="Hello", content="This is a card")
|
|
78
|
+
```
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
jx/__init__.py,sha256=MZ7KbNZkQx2D5CJpccSKuyJi179iAff3a9jG7DwEBAE,152
|
|
2
|
+
jx/attrs.py,sha256=_Cj1s4PEHiq_TiG2IvKiUkTqyThH4YgS4HwUn09zzGU,10414
|
|
3
|
+
jx/catalog.py,sha256=EaG0jNqf4JJkif9Nrnp72vEr8kX1BslJGRig9pOGgC8,11567
|
|
4
|
+
jx/component.py,sha256=9TkyX10brWRCDw6AC0tEJ70DWaDmkl_UE8i_Uqfta6M,7100
|
|
5
|
+
jx/exceptions.py,sha256=rDWxkgOqeMPptmQ21joB2ujfpIU5EwMKtAL-ImB9nuA,1518
|
|
6
|
+
jx/meta.py,sha256=lh3EFyp2_Y0R80aXaT3kVPNPcB3CREdHHePdQP4ZnR4,5148
|
|
7
|
+
jx/parser.py,sha256=LW1iHh5_Ac8B368RhfiBPe3w7_1tdLf5LjCl3rfNKu8,12868
|
|
8
|
+
jx/utils.py,sha256=dZMIAdVLdmkyPsiX14a9aIkZRfgAnf0SNEnvfyg8K98,669
|
|
9
|
+
jx-0.2.0.dist-info/licenses/LICENSE,sha256=RHwNifuIFfQM9QUhA2FQfnqlBcnhBHlJEVp8QcRllew,1076
|
|
10
|
+
jx-0.2.0.dist-info/METADATA,sha256=F0kiqI8PoNntEX_EuiOkdu9OzHbHiaYZ8eWsvAWZptA,2761
|
|
11
|
+
jx-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
12
|
+
jx-0.2.0.dist-info/top_level.txt,sha256=P61YQxqfmzVpxTMe3C48gt0vc6fnHLF8Ml0JXC-QuEI,3
|
|
13
|
+
jx-0.2.0.dist-info/RECORD,,
|
jx-0.1.0.dist-info/RECORD
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|