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 ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
3
+ """
4
+ from .catalog import CData, Catalog # noqa
5
+ from .exceptions import * # noqa
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('"', "&quot;")
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("&", "&amp;")
40
+ .replace(">", "&gt;")
41
+ .replace("<", "&lt;")
42
+ .replace("'", "&#39;")
43
+ .replace('"', "&#34;")
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,13 @@
1
+ """
2
+ Jx | Copyright (c) Juan-Pablo Scaletti <juanpablo@jpscaletti.com>
3
+ """
4
+ import logging
5
+ import uuid
6
+
7
+
8
+ logger = logging.getLogger("jx")
9
+
10
+
11
+ def get_random_id(prefix: str = "id") -> str:
12
+ return f"{prefix}-{str(uuid.uuid4().hex)}"
13
+
@@ -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,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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