Skip to content

Commit 03db96f

Browse files
committed
slots
1 parent 9a02aba commit 03db96f

File tree

7 files changed

+523
-28
lines changed

7 files changed

+523
-28
lines changed

README.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,38 @@ Super components powers for your Jinja templates.
1212
From chaos to clarity: The power of components in your server-side-rendered Python web app.
1313

1414
<!-- Documentation: https://jx.scaletti.dev/ -->
15+
16+
## How It Works
17+
18+
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.
19+
20+
### Component Definition
21+
22+
Components are defined as regular Jinja2 templates (.jinja files) with special metadata comments:
23+
24+
- `{# def parameter1 parameter2=default_value #}` - Defines required and optional parameters
25+
- `{# import "path/to/component.jinja" as ComponentName #}` - Imports other components
26+
- `{# css "/path/to/style.css" #}` - Includes CSS files
27+
- `{# js "/path/to/script.js" #}` - Includes JavaScript files
28+
29+
Example component:
30+
31+
```jinja
32+
{# def message #}
33+
{# import "button.jinja" as Button #}
34+
35+
<div class="greeting">{{ message }}</div>
36+
<Button text="OK" />
37+
```
38+
39+
### Usage Example
40+
41+
```python
42+
from jx import Catalog
43+
44+
# Create a catalog and add a components folder
45+
catalog = Catalog("templates/components")
46+
47+
# Render a component with parameters
48+
html = catalog.render("card.jinja", title="Hello", content="This is a card")
49+
```

src/jx/attrs.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,8 @@ def set(self, **kw) -> None:
155155
- The underscores in the names will be translated automatically to dashes,
156156
so `aria_selected` becomes the attribute `aria-selected`.
157157
158+
TODO: vue-style
159+
158160
Example:
159161
160162
```python

src/jx/catalog.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class CData:
2727
imports: dict[str, str] = field(default_factory=dict) # { name: relpath }
2828
css: tuple[str, ...] = ()
2929
js: tuple[str, ...] = ()
30+
slots: tuple[str, ...] = ()
3031

3132

3233
class Catalog:
@@ -217,7 +218,7 @@ def get_component_data(self, relpath: str) -> CData:
217218
source=source,
218219
components=list(meta.imports.keys())
219220
)
220-
parsed_source = parser.parse()
221+
parsed_source, slots = parser.parse()
221222
code = self.jinja_env.compile(
222223
source=parsed_source,
223224
name=relpath,
@@ -230,6 +231,7 @@ def get_component_data(self, relpath: str) -> CData:
230231
cdata.imports = meta.imports
231232
cdata.css = meta.css
232233
cdata.js = meta.js
234+
cdata.slots = slots
233235
return cdata
234236

235237
def get_component(self, relpath: str) -> Component:
@@ -256,9 +258,10 @@ def get_component(self, relpath: str) -> Component:
256258
get_component=self.get_component,
257259
required=cdata.required,
258260
optional=cdata.optional,
261+
imports=cdata.imports,
259262
css=cdata.css,
260263
js=cdata.js,
261-
imports=cdata.imports
264+
slots=cdata.slots,
262265
)
263266
return co
264267

src/jx/component.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ class Component:
2222
"get_component",
2323
"required",
2424
"optional",
25+
"imports",
2526
"css",
2627
"js",
27-
"imports",
28+
"slots",
2829
"globals",
2930
)
3031

@@ -36,9 +37,10 @@ def __init__(
3637
get_component: Callable[[str], "Component"],
3738
required: tuple[str, ...] = (),
3839
optional: dict[str, t.Any] | None = None,
40+
imports: dict[str, str] | None = None,
3941
css: tuple[str, ...] = (),
4042
js: tuple[str, ...] = (),
41-
imports: dict[str, str] | None = None,
43+
slots: tuple[str, ...] = (),
4244
) -> None:
4345
"""
4446
Internal object that represents a Jx component.
@@ -54,12 +56,14 @@ def __init__(
5456
A tuple of required attribute names.
5557
optional:
5658
A dictionary of optional attributes and their default values.
59+
imports:
60+
A dictionary of imported component names as "name": "relpath" pairs.
5761
css:
5862
A tuple of CSS file URLs.
5963
js:
6064
A tuple of JS file URLs.
61-
imports:
62-
A dictionary of imported component names as "name": "relpath" pairs.
65+
slots:
66+
A tuple of slot names.
6367
6468
"""
6569
self.relpath = relpath
@@ -68,9 +72,10 @@ def __init__(
6872

6973
self.required = required
7074
self.optional = optional or {}
75+
self.imports = imports or {}
7176
self.css = css
7277
self.js = js
73-
self.imports = imports or {}
78+
self.slots = slots
7479

7580
self.globals: dict[str, t.Any] = {}
7681

@@ -79,10 +84,10 @@ def render(
7984
*,
8085
content: str | None = None,
8186
attrs: Attrs | dict[str, t.Any] | None = None,
82-
caller: Callable[[], str] | None = None,
87+
caller: Callable[[str], str] | None = None,
8388
**params: t.Any
8489
) -> Markup:
85-
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 ""
8691
attrs = attrs.as_dict if isinstance(attrs, Attrs) else attrs or {}
8792
params = {**attrs, **params}
8893
props, attrs = self.filter_attrs(params)
@@ -91,6 +96,14 @@ def render(
9196
globals.setdefault("attrs", Attrs(attrs))
9297
globals.setdefault("content", content)
9398

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+
94107
html = self.tmpl.render({**props, **globals}).lstrip()
95108
return Markup(html)
96109

src/jx/parser.py

Lines changed: 100 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from .utils import logger
1313

1414

15-
BLOCK_CALL = '{% call _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
15+
BLOCK_CALL = '{% call(_slot="") _get("[TAG]").render([ATTRS]) -%}[CONTENT]{%- endcall %}'
1616
INLINE_CALL = '{{ _get("[TAG]").render([ATTRS]) }}'
1717

1818
re_raw = r"\{%-?\s*raw\s*-?%\}.+?\{%-?\s*endraw\s*-?%\}"
@@ -33,6 +33,17 @@
3333
"""
3434
RX_ATTR = re.compile(re_attr, re.VERBOSE | re.DOTALL)
3535

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+
3647

3748
def escape(s: t.Any, /) -> Markup:
3849
return Markup(
@@ -72,7 +83,7 @@ def __init__(
7283
self.source = source
7384
self.components = components
7485

75-
def parse(self, *, validate_tags: bool = True) -> str:
86+
def parse(self, *, validate_tags: bool = True) -> tuple[str, tuple[str, ...]]:
7687
"""
7788
Parses the template source code.
7889
@@ -81,7 +92,8 @@ def parse(self, *, validate_tags: bool = True) -> str:
8192
Whether to raise an error for unknown TitleCased tags.
8293
8394
Returns:
84-
The transformed template source code.
95+
- The transformed template source code
96+
- The list of slot names.
8597
8698
Raises:
8799
TemplateSyntaxError:
@@ -92,8 +104,9 @@ def parse(self, *, validate_tags: bool = True) -> str:
92104
source = self.source
93105
source, raw_blocks = self.replace_raw_blocks(source)
94106
source = self.process_tags(source, validate_tags=validate_tags)
107+
source, slots = self.process_slots(source)
95108
source = self.restore_raw_blocks(source, raw_blocks)
96-
return source
109+
return source, slots
97110

98111
def replace_raw_blocks(self, source: str) -> tuple[str, dict[str, str]]:
99112
"""
@@ -202,10 +215,93 @@ def replace_tag(
202215
content = source[end:index]
203216
end = index + len(close_tag)
204217

218+
if content:
219+
content = self.process_fills(content)
220+
205221
attrs = self._parse_attrs(raw_attrs)
206222
repl = self._build_call(tag, attrs, content)
207223
return f"{source[:start]}{repl}{source[end:]}"
208224

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+
209305
# Private
210306

211307
def _parse_opening_tag(

0 commit comments

Comments
 (0)