|
1 | 1 | # pyright: reportAny=false |
2 | 2 | from __future__ import annotations |
3 | 3 |
|
4 | | -from typing import Any |
5 | | -from typing import cast |
6 | | - |
7 | 4 | from django import template |
8 | | -from django.template.base import NodeList |
9 | | -from django.template.base import Parser |
10 | | -from django.template.base import Token |
11 | | -from django.template.context import Context |
12 | | -from django.template.loader import select_template |
13 | | -from django.utils.safestring import SafeString |
14 | | -from django.utils.safestring import mark_safe |
15 | 5 |
|
16 | | -from django_bird._typing import override |
17 | | -from django_bird.conf import app_settings |
| 6 | +from .tags import do_bird |
| 7 | +from .tags import do_slot |
18 | 8 |
|
19 | 9 | register = template.Library() |
20 | 10 |
|
21 | 11 |
|
22 | | -@register.tag(name="bird") |
23 | | -def do_bird(parser: Parser, token: Token) -> BirdNode: |
24 | | - bits = token.split_contents() |
25 | | - |
26 | | - if len(bits) < 2: |
27 | | - msg = f"{token.contents.split()[0]} tag requires at least one argument" |
28 | | - raise template.TemplateSyntaxError(msg) |
29 | | - |
30 | | - # {% bird name %} |
31 | | - # {% bird 'name' %} |
32 | | - # {% bird "name" %} |
33 | | - name = bits[1].strip("'\"") |
34 | | - attrs = bits[2:] |
35 | | - |
36 | | - # self-closing tag |
37 | | - # {% bird name / %} |
38 | | - if len(attrs) > 0 and attrs[-1] == "/": |
39 | | - nodelist = None |
40 | | - else: |
41 | | - nodelist = parser.parse(("endbird",)) |
42 | | - parser.delete_first_token() |
43 | | - |
44 | | - return BirdNode(name, attrs, nodelist) |
45 | | - |
46 | | - |
47 | | -class BirdNode(template.Node): |
48 | | - def __init__(self, name: str, attrs: list[str], nodelist: NodeList | None) -> None: |
49 | | - self.name = name |
50 | | - self.attrs = attrs |
51 | | - self.nodelist = nodelist |
52 | | - self.default_slot = "default" |
53 | | - |
54 | | - @override |
55 | | - def render(self, context: Context) -> SafeString: |
56 | | - component_name = self.get_component_name(context) |
57 | | - component_context = self.get_component_context_data(context) |
58 | | - template_names = self.get_template_names(component_name) |
59 | | - template = select_template(template_names) |
60 | | - return template.render(component_context) |
61 | | - |
62 | | - def get_component_name(self, context: Context) -> str: |
63 | | - try: |
64 | | - name = template.Variable(self.name).resolve(context) |
65 | | - except template.VariableDoesNotExist: |
66 | | - name = self.name |
67 | | - return name |
68 | | - |
69 | | - def get_component_context_data(self, context: Context) -> dict[str, Any]: |
70 | | - rendered_slots = self.render_slots(context) |
71 | | - flat_attrs = self.flatten_attrs(context) |
72 | | - default_slot = rendered_slots.get(self.default_slot) or context.get("slot") |
73 | | - return { |
74 | | - "attrs": mark_safe(flat_attrs), |
75 | | - "slot": mark_safe(default_slot), |
76 | | - "slots": { |
77 | | - name: mark_safe(content) for name, content in rendered_slots.items() |
78 | | - }, |
79 | | - } |
80 | | - |
81 | | - def render_slots(self, context: Context) -> dict[str, str]: |
82 | | - if self.nodelist is None: |
83 | | - return {} |
84 | | - |
85 | | - contents: dict[str, list[str]] = {self.default_slot: []} |
86 | | - active_slot = self.default_slot |
87 | | - |
88 | | - for node in self.nodelist: |
89 | | - if isinstance(node, SlotNode): |
90 | | - active_slot = node.name |
91 | | - contents.setdefault(active_slot, []) |
92 | | - else: |
93 | | - active_slot = self.default_slot |
94 | | - |
95 | | - rendered_content = node.render(context) |
96 | | - contents[active_slot].append(rendered_content) |
97 | | - |
98 | | - if ( |
99 | | - all(not content for content in contents[self.default_slot]) |
100 | | - and "slot" in context |
101 | | - ): |
102 | | - contents[self.default_slot] = [context["slot"]] |
103 | | - |
104 | | - return { |
105 | | - slot: template.Template("".join(content)).render(context) |
106 | | - for slot, content in contents.items() |
107 | | - } |
108 | | - |
109 | | - def flatten_attrs(self, context: Context) -> str: |
110 | | - attrs: dict[str, str | None | bool] = {} |
111 | | - |
112 | | - for attr in self.attrs: |
113 | | - if "=" in attr: |
114 | | - key, value = attr.split("=", 1) |
115 | | - attrs[key] = template.Variable(value).resolve(context) |
116 | | - else: |
117 | | - attrs[attr] = True |
118 | | - |
119 | | - return " ".join( |
120 | | - key if isinstance(value, bool) and value else f'{key}="{value}"' |
121 | | - for key, value in attrs.items() |
122 | | - ) |
123 | | - |
124 | | - def get_template_names(self, name: str) -> list[str]: |
125 | | - """ |
126 | | - Generate a list of potential template names for a component. |
127 | | -
|
128 | | - The function searches for templates in the following order (from most specific to most general): |
129 | | -
|
130 | | - 1. In a subdirectory named after the component, using the component name |
131 | | - 2. In the same subdirectory, using a fallback 'index.html' |
132 | | - 3. In parent directory for nested components |
133 | | - 4. In the base component directory, using the full component name |
134 | | -
|
135 | | - The order of names is important as it determines the template resolution priority. |
136 | | - This order allows for both direct matches and hierarchical component structures, |
137 | | - with more specific paths taking precedence over more general ones. |
138 | | -
|
139 | | - This order allows for: |
140 | | - - Single file components |
141 | | - - Multi-part components |
142 | | - - Specific named files within component directories |
143 | | - - Fallback default files for components |
144 | | -
|
145 | | - For example: |
146 | | - - For an "input" component, the ordering would be: |
147 | | - 1. `{component_dir}/input/input.html` |
148 | | - 2. `{component_dir}/input/index.html` |
149 | | - 3. `{component_dir}/input.html` |
150 | | - - For an "input.label" component: |
151 | | - 1. `{component_dir}/input/label/label.html` |
152 | | - 2. `{component_dir}/input/label/index.html` |
153 | | - 3. `{component_dir}/input/label.html` |
154 | | - 4. `{component_dir}/input.label.html` |
155 | | -
|
156 | | - Returns: |
157 | | - list[str]: A list of potential template names in resolution order. |
158 | | - """ |
159 | | - template_names = [] |
160 | | - component_dirs = list(dict.fromkeys([*app_settings.COMPONENT_DIRS, "bird"])) |
161 | | - |
162 | | - name_parts = name.split(".") |
163 | | - path_name = "/".join(name_parts) |
164 | | - |
165 | | - for component_dir in component_dirs: |
166 | | - potential_names = [ |
167 | | - f"{component_dir}/{path_name}/{name_parts[-1]}.html", |
168 | | - f"{component_dir}/{path_name}/index.html", |
169 | | - f"{component_dir}/{path_name}.html", |
170 | | - f"{component_dir}/{self.name}.html", |
171 | | - ] |
172 | | - template_names.extend(potential_names) |
173 | | - |
174 | | - return list(dict.fromkeys(template_names)) |
175 | | - |
176 | | - |
177 | | -@register.tag("bird:slot") |
178 | | -def do_slot(parser: Parser, token: Token) -> SlotNode: |
179 | | - bits = token.split_contents() |
180 | | - name = parse_slot_name(bits) |
181 | | - nodelist = parser.parse(("endbird:slot",)) |
182 | | - parser.delete_first_token() |
183 | | - return SlotNode(name, nodelist) |
184 | | - |
185 | | - |
186 | | -def parse_slot_name(tag_args: list[str]) -> str: |
187 | | - if len(tag_args) == 1: |
188 | | - return "default" |
189 | | - elif len(tag_args) == 2: |
190 | | - name = tag_args[1] |
191 | | - if name.startswith("name="): |
192 | | - name = name.split("=")[1] |
193 | | - else: |
194 | | - name = name |
195 | | - return name.strip("'\"") |
196 | | - else: |
197 | | - raise template.TemplateSyntaxError( |
198 | | - "slot tag requires either no arguments, one argument, or 'name=\"slot_name\"'" |
199 | | - ) |
200 | | - |
201 | | - |
202 | | -class SlotNode(template.Node): |
203 | | - def __init__(self, name: str, nodelist: NodeList): |
204 | | - self.name = name |
205 | | - self.nodelist = nodelist |
206 | | - |
207 | | - @override |
208 | | - def render(self, context: Context) -> SafeString: |
209 | | - default_content: str = self.nodelist.render(context) |
210 | | - slots = context.get("slots") |
211 | | - |
212 | | - if not slots or not isinstance(slots, dict): |
213 | | - slot_content = default_content |
214 | | - else: |
215 | | - slots_dict = cast(dict[str, str], slots) |
216 | | - slot_content = slots_dict.get(self.name, default_content) |
217 | | - |
218 | | - # Recursively process the slot content |
219 | | - t = template.Template(slot_content) |
220 | | - content = t.render(context) |
221 | | - |
222 | | - return mark_safe(content) |
| 12 | +register.tag("bird", do_bird) |
| 13 | +register.tag("bird:slot", do_slot) |
0 commit comments