pywire 0.1.1__py3-none-any.whl → 0.1.2__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.
Files changed (101) hide show
  1. pywire/__init__.py +2 -0
  2. pywire/cli/__init__.py +1 -0
  3. pywire/cli/generators.py +48 -0
  4. pywire/cli/main.py +309 -0
  5. pywire/cli/tui.py +563 -0
  6. pywire/cli/validate.py +26 -0
  7. pywire/client/.prettierignore +8 -0
  8. pywire/client/.prettierrc +7 -0
  9. pywire/client/build.mjs +73 -0
  10. pywire/client/eslint.config.js +46 -0
  11. pywire/client/package.json +39 -0
  12. pywire/client/pnpm-lock.yaml +2971 -0
  13. pywire/client/src/core/app.ts +263 -0
  14. pywire/client/src/core/dom-updater.test.ts +78 -0
  15. pywire/client/src/core/dom-updater.ts +321 -0
  16. pywire/client/src/core/index.ts +5 -0
  17. pywire/client/src/core/transport-manager.test.ts +179 -0
  18. pywire/client/src/core/transport-manager.ts +159 -0
  19. pywire/client/src/core/transports/base.ts +122 -0
  20. pywire/client/src/core/transports/http.ts +142 -0
  21. pywire/client/src/core/transports/index.ts +13 -0
  22. pywire/client/src/core/transports/websocket.ts +97 -0
  23. pywire/client/src/core/transports/webtransport.ts +149 -0
  24. pywire/client/src/dev/dev-app.ts +93 -0
  25. pywire/client/src/dev/error-trace.test.ts +97 -0
  26. pywire/client/src/dev/error-trace.ts +76 -0
  27. pywire/client/src/dev/index.ts +4 -0
  28. pywire/client/src/dev/status-overlay.ts +63 -0
  29. pywire/client/src/events/handler.test.ts +318 -0
  30. pywire/client/src/events/handler.ts +454 -0
  31. pywire/client/src/pywire.core.ts +22 -0
  32. pywire/client/src/pywire.dev.ts +27 -0
  33. pywire/client/tsconfig.json +17 -0
  34. pywire/client/vitest.config.ts +15 -0
  35. pywire/compiler/__init__.py +6 -0
  36. pywire/compiler/ast_nodes.py +304 -0
  37. pywire/compiler/attributes/__init__.py +6 -0
  38. pywire/compiler/attributes/base.py +24 -0
  39. pywire/compiler/attributes/conditional.py +37 -0
  40. pywire/compiler/attributes/events.py +55 -0
  41. pywire/compiler/attributes/form.py +37 -0
  42. pywire/compiler/attributes/loop.py +75 -0
  43. pywire/compiler/attributes/reactive.py +34 -0
  44. pywire/compiler/build.py +28 -0
  45. pywire/compiler/build_artifacts.py +342 -0
  46. pywire/compiler/codegen/__init__.py +5 -0
  47. pywire/compiler/codegen/attributes/__init__.py +6 -0
  48. pywire/compiler/codegen/attributes/base.py +19 -0
  49. pywire/compiler/codegen/attributes/events.py +35 -0
  50. pywire/compiler/codegen/directives/__init__.py +6 -0
  51. pywire/compiler/codegen/directives/base.py +16 -0
  52. pywire/compiler/codegen/directives/path.py +53 -0
  53. pywire/compiler/codegen/generator.py +2341 -0
  54. pywire/compiler/codegen/template.py +2178 -0
  55. pywire/compiler/directives/__init__.py +7 -0
  56. pywire/compiler/directives/base.py +20 -0
  57. pywire/compiler/directives/component.py +33 -0
  58. pywire/compiler/directives/context.py +93 -0
  59. pywire/compiler/directives/layout.py +49 -0
  60. pywire/compiler/directives/no_spa.py +24 -0
  61. pywire/compiler/directives/path.py +71 -0
  62. pywire/compiler/directives/props.py +88 -0
  63. pywire/compiler/exceptions.py +19 -0
  64. pywire/compiler/interpolation/__init__.py +6 -0
  65. pywire/compiler/interpolation/base.py +28 -0
  66. pywire/compiler/interpolation/jinja.py +272 -0
  67. pywire/compiler/parser.py +750 -0
  68. pywire/compiler/paths.py +29 -0
  69. pywire/compiler/preprocessor.py +43 -0
  70. pywire/core/wire.py +119 -0
  71. pywire/py.typed +0 -0
  72. pywire/runtime/__init__.py +7 -0
  73. pywire/runtime/aioquic_server.py +194 -0
  74. pywire/runtime/app.py +901 -0
  75. pywire/runtime/compile_error_page.py +195 -0
  76. pywire/runtime/debug.py +203 -0
  77. pywire/runtime/dev_server.py +434 -0
  78. pywire/runtime/dev_server.py.broken +268 -0
  79. pywire/runtime/error_page.py +64 -0
  80. pywire/runtime/error_renderer.py +23 -0
  81. pywire/runtime/escape.py +23 -0
  82. pywire/runtime/files.py +40 -0
  83. pywire/runtime/helpers.py +97 -0
  84. pywire/runtime/http_transport.py +253 -0
  85. pywire/runtime/loader.py +272 -0
  86. pywire/runtime/logging.py +72 -0
  87. pywire/runtime/page.py +384 -0
  88. pywire/runtime/pydantic_integration.py +52 -0
  89. pywire/runtime/router.py +229 -0
  90. pywire/runtime/server.py +25 -0
  91. pywire/runtime/style_collector.py +31 -0
  92. pywire/runtime/upload_manager.py +76 -0
  93. pywire/runtime/validation.py +449 -0
  94. pywire/runtime/websocket.py +665 -0
  95. pywire/runtime/webtransport_handler.py +195 -0
  96. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/METADATA +1 -1
  97. pywire-0.1.2.dist-info/RECORD +104 -0
  98. pywire-0.1.1.dist-info/RECORD +0 -9
  99. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/WHEEL +0 -0
  100. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/entry_points.txt +0 -0
  101. {pywire-0.1.1.dist-info → pywire-0.1.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2178 @@
1
+ """Template rendering code generation."""
2
+
3
+ import ast
4
+ import dataclasses
5
+ import re
6
+ from collections import defaultdict
7
+
8
+ from typing import Any, Dict, List, Optional, Set, Tuple, Union, cast
9
+
10
+ from pywire.compiler.ast_nodes import (
11
+ EventAttribute,
12
+ ForAttribute,
13
+ IfAttribute,
14
+ InterpolationNode,
15
+ KeyAttribute,
16
+ ReactiveAttribute,
17
+ ShowAttribute,
18
+ TemplateNode,
19
+ )
20
+ from pywire.compiler.interpolation.jinja import JinjaInterpolationParser
21
+
22
+
23
+ class TemplateCodegen:
24
+ """Generates Python AST for rendering template."""
25
+
26
+ # HTML void elements that don't have closing tags
27
+ VOID_ELEMENTS = {
28
+ "area",
29
+ "base",
30
+ "br",
31
+ "col",
32
+ "embed",
33
+ "hr",
34
+ "img",
35
+ "input",
36
+ "link",
37
+ "meta",
38
+ "param",
39
+ "source",
40
+ "track",
41
+ "wbr",
42
+ "slot",
43
+ }
44
+
45
+ def __init__(self) -> None:
46
+ self.interpolation_parser = JinjaInterpolationParser()
47
+ self._slot_default_counter = 0
48
+ self.auxiliary_functions: List[ast.AsyncFunctionDef] = []
49
+ self.has_file_inputs = False
50
+ self._region_counter = 0
51
+ self.region_renderers: Dict[str, str] = {}
52
+
53
+ def generate_render_method(
54
+ self,
55
+ template_nodes: List[TemplateNode],
56
+ layout_id: Optional[str] = None,
57
+ known_methods: Optional[Set[str]] = None,
58
+ known_globals: Optional[Set[str]] = None,
59
+ async_methods: Optional[Set[str]] = None,
60
+ component_map: Optional[Dict[str, str]] = None,
61
+ scope_id: Optional[str] = None,
62
+ initial_locals: Optional[Set[str]] = None,
63
+ ) -> Tuple[ast.AsyncFunctionDef, List[ast.AsyncFunctionDef]]:
64
+ """
65
+ Generate standard _render_template method.
66
+ Returns: (main_function_ast, list_of_auxiliary_function_asts)
67
+ """
68
+ self._reset_state()
69
+ # Check for explicit spread
70
+ has_spread = self._has_spread_attribute(template_nodes)
71
+ implicit_root_source = "attrs" if not has_spread and layout_id else None
72
+
73
+ main_func = self._generate_function(
74
+ template_nodes,
75
+ "_render_template",
76
+ is_async=True,
77
+ layout_id=layout_id,
78
+ known_methods=known_methods,
79
+ known_globals=known_globals,
80
+ async_methods=async_methods,
81
+ component_map=component_map,
82
+ scope_id=scope_id,
83
+ initial_locals=initial_locals,
84
+ implicit_root_source=implicit_root_source,
85
+ )
86
+ return main_func, self.auxiliary_functions
87
+
88
+ def generate_slot_methods(
89
+ self,
90
+ template_nodes: List[TemplateNode],
91
+ file_id: str = "",
92
+ known_globals: Optional[Set[str]] = None,
93
+ layout_id: Optional[str] = None,
94
+ component_map: Optional[Dict[str, str]] = None,
95
+ ) -> Tuple[Dict[str, ast.AsyncFunctionDef], List[ast.AsyncFunctionDef]]:
96
+ """
97
+ Generate slot filler methods for child pages.
98
+ Returns: ({slot_name: function_ast}, list_of_auxiliary_function_asts)
99
+ """
100
+ self._reset_state()
101
+ slots = defaultdict(list)
102
+
103
+ # Generate a short hash from file_id to make method names unique per file
104
+ import hashlib
105
+
106
+ file_hash = hashlib.md5(file_id.encode()).hexdigest()[:8] if file_id else ""
107
+
108
+ # 1. Bucket nodes into slots based on wrapper elements
109
+ for node in template_nodes:
110
+ if node.tag == "slot" and node.attributes and "name" in node.attributes:
111
+ slot_name = node.attributes["name"]
112
+ for child in node.children:
113
+ slots[slot_name].append(child)
114
+ elif node.tag == "pywire-head":
115
+ for child in node.children:
116
+ slots["$head"].append(child)
117
+ else:
118
+ slots["default"].append(node)
119
+
120
+ # 2. Generate functions for each slot
121
+ slot_funcs = {}
122
+ for slot_name, nodes in slots.items():
123
+ safe_name = (
124
+ slot_name.replace("$", "_head_").replace("-", "_")
125
+ if slot_name.startswith("$")
126
+ else slot_name.replace("-", "_")
127
+ )
128
+ func_name = (
129
+ f"_render_slot_fill_{safe_name}_{file_hash}"
130
+ if file_hash
131
+ else f"_render_slot_fill_{safe_name}"
132
+ )
133
+ slot_funcs[slot_name] = self._generate_function(
134
+ nodes,
135
+ func_name,
136
+ is_async=True,
137
+ known_globals=known_globals,
138
+ layout_id=layout_id,
139
+ component_map=component_map,
140
+ )
141
+
142
+ return slot_funcs, self.auxiliary_functions
143
+
144
+ def _reset_state(self) -> None:
145
+ self._slot_default_counter = 0
146
+ self.auxiliary_functions = []
147
+ self.has_file_inputs = False
148
+ self._region_counter = 0
149
+ self.region_renderers = {}
150
+
151
+ def _generate_function(
152
+ self,
153
+ nodes: List[TemplateNode],
154
+ func_name: str,
155
+ is_async: bool = False,
156
+ layout_id: Optional[str] = None,
157
+ known_methods: Optional[Set[str]] = None,
158
+ known_globals: Optional[Set[str]] = None,
159
+ async_methods: Optional[Set[str]] = None,
160
+ component_map: Optional[Dict[str, str]] = None,
161
+ scope_id: Optional[str] = None,
162
+ initial_locals: Optional[Set[str]] = None,
163
+ implicit_root_source: Optional[str] = None,
164
+ enable_regions: bool = True,
165
+ root_region_id: Optional[str] = None,
166
+ ) -> ast.AsyncFunctionDef:
167
+ """Generate a single function body as AST."""
168
+
169
+ if initial_locals is None:
170
+ initial_locals = set()
171
+ else:
172
+ initial_locals = initial_locals.copy()
173
+
174
+ if known_methods is None:
175
+ known_methods = set()
176
+ if known_globals is None:
177
+ known_globals = set()
178
+ if async_methods is None:
179
+ async_methods = set()
180
+ if component_map is None:
181
+ component_map = {}
182
+
183
+ # 'json' is imported in the body, so we treat it as local to avoid transforming to self.json
184
+ initial_locals.add("json")
185
+
186
+ # parts = []
187
+ body: List[ast.stmt] = [
188
+ ast.Assign(
189
+ targets=[ast.Name(id="parts", ctx=ast.Store())],
190
+ value=ast.List(elts=[], ctx=ast.Load()),
191
+ ),
192
+ ast.Import(names=[ast.alias(name="json", asname=None)]),
193
+ # import helper
194
+ ast.ImportFrom(
195
+ module="pywire.runtime.helpers",
196
+ names=[ast.alias(name="ensure_async_iterator", asname=None)],
197
+ level=0,
198
+ ),
199
+ # import escape_html for XSS prevention
200
+ ast.ImportFrom(
201
+ module="pywire.runtime.escape",
202
+ names=[ast.alias(name="escape_html", asname=None)],
203
+ level=0,
204
+ ),
205
+ ]
206
+
207
+ root_element = self._get_root_element(nodes)
208
+
209
+ for node in nodes:
210
+ # Pass implicit root source ONLY to the root element if it matches
211
+ node_root_source = (
212
+ implicit_root_source
213
+ if (implicit_root_source and node is root_element)
214
+ else None
215
+ )
216
+ node_region_id = (
217
+ root_region_id if (root_region_id and node is root_element) else None
218
+ )
219
+
220
+ self._add_node(
221
+ node,
222
+ body,
223
+ layout_id=layout_id,
224
+ known_methods=known_methods,
225
+ known_globals=known_globals,
226
+ async_methods=async_methods,
227
+ component_map=component_map,
228
+ scope_id=scope_id,
229
+ local_vars=initial_locals,
230
+ implicit_root_source=node_root_source,
231
+ enable_regions=enable_regions,
232
+ region_id=node_region_id,
233
+ )
234
+
235
+ # return "".join(parts)
236
+ body.append(
237
+ ast.Return(
238
+ value=ast.Call(
239
+ func=ast.Attribute(
240
+ value=ast.Constant(value=""), attr="join", ctx=ast.Load()
241
+ ),
242
+ args=[ast.Name(id="parts", ctx=ast.Load())],
243
+ keywords=[],
244
+ )
245
+ )
246
+ )
247
+
248
+ func_def = ast.AsyncFunctionDef(
249
+ name=func_name,
250
+ args=ast.arguments(
251
+ posonlyargs=[],
252
+ args=[ast.arg(arg="self")],
253
+ vararg=None,
254
+ kwonlyargs=[],
255
+ kw_defaults=[],
256
+ defaults=[],
257
+ ),
258
+ body=body,
259
+ decorator_list=[],
260
+ returns=None,
261
+ )
262
+ # We don't set lineno on the function def itself as it's generated,
263
+ # but we could set it to the first node's line?
264
+ # Better to leave it (defaults to 1?) or set to 0.
265
+ # The body statements will have correct linenos.
266
+ return func_def
267
+
268
+ def _transform_expr(
269
+ self,
270
+ expr_str: str,
271
+ local_vars: Set[str],
272
+ known_globals: Optional[Set[str]] = None,
273
+ line_offset: int = 0,
274
+ col_offset: int = 0,
275
+ ) -> ast.expr:
276
+ """Transform expression string to AST with self. handling."""
277
+ expr_str = expr_str.strip()
278
+
279
+ try:
280
+ from pywire.compiler.preprocessor import preprocess_python_code
281
+
282
+ expr_str = preprocess_python_code(expr_str)
283
+ tree = ast.parse(expr_str, mode="eval")
284
+ if line_offset > 0:
285
+ # ast.increment_lineno uses 1-based indexing for AST, but adds diff
286
+ # We want result to be line_offset.
287
+ # Current starts at 1.
288
+ # diff = line_offset - 1
289
+ ast.increment_lineno(tree, line_offset - 1)
290
+ except SyntaxError:
291
+ # Fallback for complex/invalid syntax (legacy support)
292
+ # Try regex replacement then parse
293
+ def repl(m: re.Match) -> str:
294
+ word = str(m.group(1))
295
+ if word in local_vars:
296
+ return word
297
+ if known_globals is not None and word in known_globals:
298
+ return word
299
+ keywords = {
300
+ "if",
301
+ "else",
302
+ "and",
303
+ "or",
304
+ "not",
305
+ "in",
306
+ "is",
307
+ "True",
308
+ "False",
309
+ "None",
310
+ }
311
+ if word in keywords:
312
+ return word
313
+ return f"self.{word}"
314
+
315
+ replaced = re.sub(r"\\b([a-zA-Z_]\w*)\\b(?!\s*[(\[])", repl, expr_str)
316
+ tree = ast.parse(replaced, mode="eval")
317
+
318
+ class AddSelfTransformer(ast.NodeTransformer):
319
+ def visit_Name(self, node: ast.Name) -> Any:
320
+ import builtins
321
+
322
+ # 1. If locally defined, keep as is
323
+ if node.id in local_vars:
324
+ return node
325
+
326
+ # 2. If explicitly known as global/instance var, transform to self.<name>
327
+ if known_globals is not None and node.id in known_globals:
328
+ return ast.Attribute(
329
+ value=ast.Name(id="self", ctx=ast.Load()),
330
+ attr=node.id,
331
+ ctx=node.ctx,
332
+ )
333
+
334
+ # 3. If builtin, keep as is (unless matched by step 1/2)
335
+ if node.id in dir(builtins):
336
+ return node
337
+
338
+ # 4. Otherwise, assume implicit instance attribute
339
+ return ast.Attribute(
340
+ value=ast.Name(id="self", ctx=ast.Load()),
341
+ attr=node.id,
342
+ ctx=node.ctx,
343
+ )
344
+
345
+ new_tree = AddSelfTransformer().visit(tree)
346
+ # Returns the expression node
347
+ return cast(ast.Expression, new_tree).body
348
+
349
+ def _transform_reactive_expr(
350
+ self,
351
+ expr_str: str,
352
+ local_vars: Set[str],
353
+ known_methods: Optional[Set[str]] = None,
354
+ known_globals: Optional[Set[str]] = None,
355
+ async_methods: Optional[Set[str]] = None,
356
+ line_offset: int = 0,
357
+ col_offset: int = 0,
358
+ ) -> ast.expr:
359
+ """Transform reactive expression to AST, handling async calls and self."""
360
+ base_expr = self._transform_expr(
361
+ expr_str, local_vars, known_globals, line_offset, col_offset
362
+ )
363
+
364
+ # Auto-call if it matches self.method
365
+ if (
366
+ isinstance(base_expr, ast.Attribute)
367
+ and isinstance(base_expr.value, ast.Name)
368
+ and base_expr.value.id == "self"
369
+ ):
370
+ if known_methods and base_expr.attr in known_methods:
371
+ base_expr = ast.Call(func=base_expr, args=[], keywords=[])
372
+
373
+ # Async handling
374
+ if async_methods:
375
+
376
+ class AsyncAwaiter(ast.NodeTransformer):
377
+ def __init__(self) -> None:
378
+ self.in_await = False
379
+
380
+ def visit_Await(self, node: ast.Await) -> Any:
381
+ self.in_await = True
382
+ self.generic_visit(node)
383
+ self.in_await = False
384
+ return node
385
+
386
+ def visit_Call(self, node: ast.Call) -> Any:
387
+ # Check if already awaited
388
+ if self.in_await:
389
+ return self.generic_visit(node)
390
+
391
+ if (
392
+ isinstance(node.func, ast.Attribute)
393
+ and isinstance(node.func.value, ast.Name)
394
+ and node.func.value.id == "self"
395
+ and async_methods is not None
396
+ and node.func.attr in async_methods
397
+ ):
398
+ return ast.Await(value=node)
399
+ return self.generic_visit(node)
400
+
401
+ # Wrap in Module/Expr to visit
402
+ mod = ast.Module(body=[ast.Expr(value=base_expr)], type_ignores=[])
403
+ AsyncAwaiter().visit(mod)
404
+ base_expr = cast(ast.Expr, mod.body[0]).value
405
+
406
+ return base_expr
407
+
408
+ def _wrap_unwrap_wire(self, expr: ast.expr) -> ast.expr:
409
+ return ast.Call(
410
+ func=ast.Name(id="unwrap_wire", ctx=ast.Load()),
411
+ args=[expr],
412
+ keywords=[],
413
+ )
414
+
415
+ def _next_region_id(self) -> str:
416
+ self._region_counter += 1
417
+ return f"r{self._region_counter}"
418
+
419
+ def _node_is_dynamic(
420
+ self, node: TemplateNode, known_globals: Optional[Set[str]] = None
421
+ ) -> bool:
422
+ if node.tag is None:
423
+ if any(
424
+ isinstance(attr, InterpolationNode) for attr in node.special_attributes
425
+ ):
426
+ return True
427
+ if node.text_content and not node.is_raw:
428
+ parts = self.interpolation_parser.parse(
429
+ node.text_content, node.line, node.column
430
+ )
431
+ return any(isinstance(part, InterpolationNode) for part in parts)
432
+ return False
433
+
434
+ for attr in node.special_attributes:
435
+ if isinstance(attr, EventAttribute):
436
+ continue
437
+ return True
438
+
439
+ return any(
440
+ self._node_is_dynamic(child, known_globals) for child in node.children
441
+ )
442
+
443
+ def _generate_region_method(
444
+ self,
445
+ node: TemplateNode,
446
+ func_name: str,
447
+ region_id: str,
448
+ layout_id: Optional[str],
449
+ known_methods: Optional[Set[str]],
450
+ known_globals: Optional[Set[str]],
451
+ async_methods: Optional[Set[str]],
452
+ component_map: Optional[Dict[str, str]],
453
+ scope_id: Optional[str],
454
+ implicit_root_source: Optional[str],
455
+ ) -> ast.AsyncFunctionDef:
456
+ func_def = self._generate_function(
457
+ [node],
458
+ func_name,
459
+ is_async=True,
460
+ layout_id=layout_id,
461
+ known_methods=known_methods,
462
+ known_globals=known_globals,
463
+ async_methods=async_methods,
464
+ component_map=component_map,
465
+ scope_id=scope_id,
466
+ implicit_root_source=implicit_root_source,
467
+ enable_regions=False,
468
+ root_region_id=region_id,
469
+ )
470
+
471
+ if len(func_def.body) < 3:
472
+ return func_def
473
+
474
+ setup = func_def.body[:3]
475
+ render_body = func_def.body[3:]
476
+
477
+ begin_render = ast.Expr(
478
+ value=ast.Call(
479
+ func=ast.Attribute(
480
+ value=ast.Name(id="self", ctx=ast.Load()),
481
+ attr="_begin_region_render",
482
+ ctx=ast.Load(),
483
+ ),
484
+ args=[ast.Constant(value=region_id)],
485
+ keywords=[],
486
+ )
487
+ )
488
+ render_body.insert(0, begin_render)
489
+
490
+ token_assign = ast.Assign(
491
+ targets=[ast.Name(id="_render_token", ctx=ast.Store())],
492
+ value=ast.Call(
493
+ func=ast.Name(id="set_render_context", ctx=ast.Load()),
494
+ args=[
495
+ ast.Name(id="self", ctx=ast.Load()),
496
+ ast.Constant(value=region_id),
497
+ ],
498
+ keywords=[],
499
+ ),
500
+ )
501
+
502
+ reset_stmt = ast.Expr(
503
+ value=ast.Call(
504
+ func=ast.Name(id="reset_render_context", ctx=ast.Load()),
505
+ args=[ast.Name(id="_render_token", ctx=ast.Load())],
506
+ keywords=[],
507
+ )
508
+ )
509
+
510
+ func_def.body = setup + [
511
+ token_assign,
512
+ ast.Try(body=render_body, orelse=[], finalbody=[reset_stmt], handlers=[]),
513
+ ]
514
+ return func_def
515
+
516
+ def _has_spread_attribute(self, nodes: List[TemplateNode]) -> bool:
517
+ """Check if any node in the tree has a SpreadAttribute."""
518
+ from pywire.compiler.ast_nodes import SpreadAttribute
519
+
520
+ for node in nodes:
521
+ if any(isinstance(a, SpreadAttribute) for a in node.special_attributes):
522
+ return True
523
+ if self._has_spread_attribute(node.children):
524
+ return True
525
+ return False
526
+
527
+ def _get_root_element(self, nodes: List[TemplateNode]) -> Optional[TemplateNode]:
528
+ """Find the single root element if it exists (ignoring text/whitespace and metadata)."""
529
+ # Exclude style and script tags from root consideration
530
+ elements = [
531
+ n
532
+ for n in nodes
533
+ if n.tag is not None and n.tag.lower() not in ("style", "script")
534
+ ]
535
+ if len(elements) == 1:
536
+ return elements[0]
537
+ return None
538
+
539
+ def _set_line(self, node: ast.AST, template_node: TemplateNode) -> ast.AST:
540
+ """Helper to set line number on AST node."""
541
+ if template_node.line > 0 and hasattr(node, "lineno"):
542
+ node.lineno = template_node.line
543
+ node.col_offset = template_node.column # type: ignore
544
+ node.end_lineno = template_node.line # type: ignore # Single line approximation
545
+ node.end_col_offset = template_node.column + 1 # type: ignore
546
+ return node
547
+
548
+ def _add_node(
549
+ self,
550
+ node: TemplateNode,
551
+ body: List[ast.stmt],
552
+ local_vars: Optional[Set[str]] = None,
553
+ bound_var: Union[str, ast.expr, None] = None,
554
+ layout_id: Optional[str] = None,
555
+ known_methods: Optional[Set[str]] = None,
556
+ known_globals: Optional[Set[str]] = None,
557
+ async_methods: Optional[Set[str]] = None,
558
+ component_map: Optional[Dict[str, str]] = None,
559
+ scope_id: Optional[str] = None,
560
+ parts_var: str = "parts",
561
+ implicit_root_source: Optional[str] = None,
562
+ enable_regions: bool = True,
563
+ region_id: Optional[str] = None,
564
+ ) -> None:
565
+ if local_vars is None:
566
+ local_vars = set()
567
+ else:
568
+ local_vars = local_vars.copy()
569
+
570
+ # Ensure helper availability
571
+ # We can't easily check if already imported in this scope, but
572
+ # re-import is cheap inside func or we assume generator handles it.
573
+ # TemplateCodegen usually assumes outside context.
574
+ # But wait, helper functions generated by this class do imports.
575
+ # Let's add import if we are about to use render_attrs?
576
+ # Easier to ensure it's imported at top of _render_template in
577
+ # generator.py?
578
+ # No, generator.py calls this.
579
+ # We can add a "has_render_attrs_usage" flag or just import it in the generated body
580
+ # if implicit_root_source is set or spread attr found.
581
+ # Let's just rely on generator to import common helpers, or add specific
582
+ # import here if needed.
583
+ # Actually existing code imports `ensure_async_iterator` locally (line 271).
584
+ pass
585
+
586
+ # 1. Handle $for
587
+ for_attr = next(
588
+ (a for a in node.special_attributes if isinstance(a, ForAttribute)), None
589
+ )
590
+ if for_attr:
591
+ loop_vars_str = for_attr.loop_vars
592
+ new_locals = local_vars.copy()
593
+
594
+ # Parse loop vars to handle tuple unpacking
595
+ # "x, y" -> targets
596
+ assign_stmt = ast.parse(f"{loop_vars_str} = 1").body[0]
597
+ assert isinstance(assign_stmt, ast.Assign)
598
+ loop_targets_node = assign_stmt.targets[0]
599
+
600
+ def extract_names(n: ast.AST) -> None:
601
+ if isinstance(n, ast.Name):
602
+ new_locals.add(n.id)
603
+ elif isinstance(n, (ast.Tuple, ast.List)):
604
+ for elt in n.elts:
605
+ extract_names(elt)
606
+
607
+ extract_names(loop_targets_node)
608
+
609
+ iterable_expr = self._transform_expr(
610
+ for_attr.iterable,
611
+ local_vars,
612
+ known_globals,
613
+ line_offset=node.line,
614
+ col_offset=node.column,
615
+ )
616
+
617
+ for_body: List[ast.stmt] = []
618
+
619
+ new_attrs = [a for a in node.special_attributes if a is not for_attr]
620
+ if node.tag == "template":
621
+ for child in node.children:
622
+ self._add_node(
623
+ child,
624
+ for_body,
625
+ new_locals,
626
+ bound_var,
627
+ layout_id,
628
+ known_methods,
629
+ known_globals,
630
+ async_methods,
631
+ component_map,
632
+ scope_id,
633
+ parts_var=parts_var,
634
+ enable_regions=enable_regions,
635
+ )
636
+ else:
637
+ new_node = dataclasses.replace(node, special_attributes=new_attrs)
638
+ self._add_node(
639
+ new_node,
640
+ for_body,
641
+ new_locals,
642
+ bound_var,
643
+ layout_id,
644
+ known_methods,
645
+ known_globals,
646
+ async_methods,
647
+ component_map,
648
+ scope_id,
649
+ parts_var=parts_var,
650
+ enable_regions=enable_regions,
651
+ )
652
+
653
+ # Wrap iterable in ensure_async_iterator
654
+ wrapped_iterable = ast.Call(
655
+ func=ast.Name(id="ensure_async_iterator", ctx=ast.Load()),
656
+ args=[iterable_expr],
657
+ keywords=[],
658
+ )
659
+
660
+ for_stmt = ast.AsyncFor(
661
+ target=loop_targets_node,
662
+ iter=wrapped_iterable,
663
+ body=for_body,
664
+ orelse=[],
665
+ )
666
+ # Tag with line number
667
+ self._set_line(for_stmt, node)
668
+ body.append(for_stmt)
669
+ return
670
+
671
+ # 2. Handle $if
672
+ if_attr = next(
673
+ (a for a in node.special_attributes if isinstance(a, IfAttribute)), None
674
+ )
675
+ if if_attr:
676
+ cond_expr = self._transform_expr(
677
+ if_attr.condition,
678
+ local_vars,
679
+ known_globals,
680
+ line_offset=node.line,
681
+ col_offset=node.column,
682
+ )
683
+
684
+ if_body: List[ast.stmt] = []
685
+ new_attrs = [a for a in node.special_attributes if a is not if_attr]
686
+ new_node = dataclasses.replace(node, special_attributes=new_attrs)
687
+ self._add_node(
688
+ new_node,
689
+ if_body,
690
+ local_vars,
691
+ bound_var,
692
+ layout_id,
693
+ known_methods,
694
+ known_globals,
695
+ async_methods,
696
+ component_map,
697
+ scope_id,
698
+ parts_var=parts_var,
699
+ enable_regions=enable_regions,
700
+ )
701
+
702
+ if_stmt = ast.If(test=cond_expr, body=if_body, orelse=[])
703
+
704
+ if_stmt = ast.If(test=cond_expr, body=if_body, orelse=[])
705
+ self._set_line(if_stmt, node)
706
+ body.append(if_stmt)
707
+ return
708
+
709
+ # --- Handle <slot> ---
710
+ if node.tag == "slot":
711
+ slot_name = node.attributes.get("name", "default")
712
+ is_head_slot = "$head" in node.attributes
713
+
714
+ default_renderer_arg: ast.expr = ast.Constant(value=None)
715
+ if node.children:
716
+ self._slot_default_counter += 1
717
+ func_name = (
718
+ f"_render_slot_default_{slot_name}_{self._slot_default_counter}"
719
+ )
720
+ aux_func = self._generate_function(
721
+ node.children, func_name, is_async=True
722
+ )
723
+ self.auxiliary_functions.append(aux_func)
724
+ default_renderer_arg = ast.Attribute(
725
+ value=ast.Name(id="self", ctx=ast.Load()),
726
+ attr=func_name,
727
+ ctx=ast.Load(),
728
+ )
729
+
730
+ call_kwargs = [
731
+ ast.keyword(arg="default_renderer", value=default_renderer_arg),
732
+ ast.keyword(
733
+ arg="layout_id",
734
+ value=ast.Constant(value=layout_id)
735
+ if layout_id
736
+ else ast.Call(
737
+ func=ast.Name(id="getattr", ctx=ast.Load()),
738
+ args=[
739
+ ast.Name(id="self", ctx=ast.Load()),
740
+ ast.Constant(value="LAYOUT_ID"),
741
+ ast.Constant(value=None),
742
+ ],
743
+ keywords=[],
744
+ ),
745
+ ),
746
+ ]
747
+
748
+ if is_head_slot:
749
+ call_kwargs.append(
750
+ ast.keyword(arg="append", value=ast.Constant(value=True))
751
+ )
752
+
753
+ render_call = ast.Call(
754
+ func=ast.Attribute(
755
+ value=ast.Name(id="self", ctx=ast.Load()),
756
+ attr="render_slot",
757
+ ctx=ast.Load(),
758
+ ),
759
+ args=[ast.Constant(value=slot_name)],
760
+ keywords=call_kwargs,
761
+ )
762
+
763
+ append_stmt = ast.Expr(
764
+ value=ast.Call(
765
+ func=ast.Attribute(
766
+ value=ast.Name(id="parts", ctx=ast.Load()),
767
+ attr="append",
768
+ ctx=ast.Load(),
769
+ ),
770
+ args=[ast.Await(value=render_call)],
771
+ keywords=[],
772
+ )
773
+ )
774
+
775
+ append_stmt = ast.Expr(
776
+ value=ast.Call(
777
+ func=ast.Attribute(
778
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
779
+ attr="append",
780
+ ctx=ast.Load(),
781
+ ),
782
+ args=[ast.Await(value=render_call)],
783
+ keywords=[],
784
+ )
785
+ )
786
+ self._set_line(append_stmt, node)
787
+ body.append(append_stmt)
788
+ return
789
+
790
+ if component_map and node.tag in component_map:
791
+ cls_name = component_map[node.tag]
792
+
793
+ # Prepare arguments (kwargs)
794
+ # Prepare arguments (kwargs dict keys/values)
795
+ dict_keys: List[Optional[ast.expr]] = []
796
+ dict_values: List[ast.expr] = []
797
+
798
+ # 1. Pass implicit context props (request, params, etc.)
799
+ for ctx_prop in ["request", "params", "query", "path", "url"]:
800
+ dict_keys.append(ast.Constant(value=ctx_prop))
801
+ dict_values.append(
802
+ ast.Attribute(
803
+ value=ast.Name(id="self", ctx=ast.Load()),
804
+ attr=ctx_prop,
805
+ ctx=ast.Load(),
806
+ )
807
+ )
808
+
809
+ # Pass __is_component__ flag
810
+ dict_keys.append(ast.Constant(value="__is_component__"))
811
+ dict_values.append(ast.Constant(value=True))
812
+
813
+ # Pass style collector
814
+ dict_keys.append(ast.Constant(value="_style_collector"))
815
+ dict_values.append(
816
+ ast.Attribute(
817
+ value=ast.Name(id="self", ctx=ast.Load()),
818
+ attr="_style_collector",
819
+ ctx=ast.Load(),
820
+ )
821
+ )
822
+
823
+ # Pass context for !provide/!inject
824
+ dict_keys.append(ast.Constant(value="_context"))
825
+ dict_values.append(
826
+ ast.Attribute(
827
+ value=ast.Name(id="self", ctx=ast.Load()),
828
+ attr="context",
829
+ ctx=ast.Load(),
830
+ )
831
+ )
832
+
833
+ # 2. Pass explicitly defined props (static)
834
+ for k, v in node.attributes.items():
835
+ dict_keys.append(ast.Constant(value=k))
836
+
837
+ val_expr = None
838
+ if "{" in v and "}" in v:
839
+ v_stripped = v.strip()
840
+ if (
841
+ v_stripped.startswith("{")
842
+ and v_stripped.endswith("}")
843
+ and v_stripped.count("{") == 1
844
+ ):
845
+ # Single expression
846
+ expr_code = v_stripped[1:-1]
847
+ val_expr = self._transform_expr(
848
+ expr_code,
849
+ local_vars,
850
+ known_globals,
851
+ line_offset=node.line,
852
+ col_offset=node.column,
853
+ )
854
+ else:
855
+ # String interpolation
856
+ parts = self.interpolation_parser.parse(
857
+ v, node.line, node.column
858
+ )
859
+ current_concat: Optional[ast.expr] = None
860
+ for part in parts:
861
+ term: ast.expr
862
+ if isinstance(part, str):
863
+ term = ast.Constant(value=part)
864
+ else:
865
+ term = ast.Call(
866
+ func=ast.Name(id="str", ctx=ast.Load()),
867
+ args=[
868
+ self._transform_expr(
869
+ part.expression,
870
+ local_vars,
871
+ known_globals,
872
+ line_offset=part.line,
873
+ col_offset=part.column,
874
+ )
875
+ ],
876
+ keywords=[],
877
+ )
878
+
879
+ if current_concat is None:
880
+ current_concat = term
881
+ else:
882
+ current_concat = ast.BinOp(
883
+ left=current_concat, op=ast.Add(), right=term
884
+ )
885
+ val_expr = (
886
+ current_concat if current_concat else ast.Constant(value="")
887
+ )
888
+ else:
889
+ # Static string
890
+ val_expr = ast.Constant(value=v)
891
+
892
+ dict_values.append(val_expr)
893
+
894
+ # 3. Handle special attributes
895
+ # from pywire.compiler.ast_nodes import ReactiveAttribute, EventAttribute
896
+ # # Shadowing global
897
+
898
+ # Group events by type for batch handling logic
899
+ event_attrs_by_type = defaultdict(list)
900
+ for attr in node.special_attributes:
901
+ if isinstance(attr, EventAttribute):
902
+ event_attrs_by_type[attr.event_type].append(attr)
903
+
904
+ # Process non-event special attributes (Reactive) and Events
905
+ for attr in node.special_attributes:
906
+ if isinstance(attr, ReactiveAttribute):
907
+ dict_keys.append(ast.Constant(value=attr.name))
908
+ expr = self._transform_reactive_expr(
909
+ attr.expr,
910
+ local_vars,
911
+ known_methods,
912
+ known_globals,
913
+ async_methods,
914
+ line_offset=node.line,
915
+ col_offset=node.column,
916
+ )
917
+ dict_values.append(expr)
918
+
919
+ # Compile events into data-on-* attributes to pass as props
920
+ # This logic mirrors the standard element event generation
921
+ for event_type, attrs_list in event_attrs_by_type.items():
922
+ if len(attrs_list) == 1:
923
+ # Single handler
924
+ attr = attrs_list[0]
925
+
926
+ # data-on-X
927
+ dict_keys.append(ast.Constant(value=f"data-on-{event_type}"))
928
+
929
+ # Resolve handler string/expr
930
+ raw_handler = attr.handler_name
931
+ if raw_handler.strip().startswith(
932
+ "{"
933
+ ) and raw_handler.strip().endswith("}"):
934
+ # New syntax: {expr} -> Evaluate it?
935
+ # Wait, standard event logic treats handler_name as STRING NAME usually.
936
+ # If it's an expression like {print('hi')}, it evaluates to None.
937
+ # We need to register it?
938
+ # Actually, standard element logic (lines 880+) sets value=ast.Constant(
939
+ # value=attr.handler_name
940
+ # ).
941
+ # It assumes the handler_name is a STRING that refers to a method.
942
+ # OR it assumes the runtime handles looking it up?
943
+ # If user wrote @click={print('hi')}, the parser makes
944
+ # handler_name="{print('hi')}".
945
+ # The standard logic just dumps that string?
946
+ # Let's check runtime/client code.
947
+ # If client receives data-on-click="{print('hi')}", it likely tries to
948
+ # eval/run it within context.
949
+ # So we should pass it AS A STRING.
950
+ # BUT, if we evaluated it in my previous attempt (`val =
951
+ # transform_expr...`), we passed the RESULT (None).
952
+
953
+ # CORRECT APPROACH: Pass the handler identifier string or expression
954
+ # string AS IS.
955
+ # The client side `pywire.js` parses the `data-on-click` value.
956
+ # If it's a method name "onClick", it calls it.
957
+ # If it's code "print('hi')", it might eval it?
958
+ # Actually pywire seems to rely on named handlers mostly.
959
+ # The `run_demo_test` output showed: `data-on-click="<bound method...>"`
960
+ # That happened because I evaluated it.
961
+ # If I pass the raw string "print('hi')", it will render as
962
+ # `data-on-click="print('hi')"`.
963
+ # Does the client support eval?
964
+ # Looking at `attributes/events.py`, parser stores raw string.
965
+
966
+ dict_values.append(ast.Constant(value=attr.handler_name))
967
+
968
+ else:
969
+ dict_values.append(ast.Constant(value=attr.handler_name))
970
+
971
+ # Modifiers
972
+ if attr.modifiers:
973
+ dict_keys.append(
974
+ ast.Constant(value=f"data-modifiers-{event_type}")
975
+ )
976
+ dict_values.append(ast.Constant(value=" ".join(attr.modifiers)))
977
+
978
+ # Args
979
+ for i, arg_expr in enumerate(attr.args):
980
+ dict_keys.append(ast.Constant(value=f"data-arg-{i}"))
981
+ # Evaluate arg expr and json dump
982
+ val = self._transform_expr(
983
+ arg_expr,
984
+ local_vars,
985
+ known_globals,
986
+ line_offset=node.line,
987
+ col_offset=node.column,
988
+ )
989
+ dump_call = ast.Call(
990
+ func=ast.Attribute(
991
+ value=ast.Name(id="json", ctx=ast.Load()),
992
+ attr="dumps",
993
+ ctx=ast.Load(),
994
+ ),
995
+ args=[val],
996
+ keywords=[],
997
+ )
998
+ dict_values.append(dump_call)
999
+
1000
+ else:
1001
+ # Multiple handlers -> compile to JSON structure
1002
+ # We need to construct the list of dicts at runtime and json dump it
1003
+ # This is complex to do inline in dict_values construction.
1004
+ # Helper var needed?
1005
+ # We are inside `_add_node` building `body`.
1006
+ # We can prepend statements to `body` to build the list, then reference it.
1007
+ # But here we are building `dict_values` list for the `ast.Dict`.
1008
+ # We can put an `ast.Call` that invokes `json.dumps` on a list comprehension?
1009
+ # Or simpler: Just emit the logic to build the list into a temp var, use temp
1010
+ # var here.
1011
+
1012
+ # Generate temp var name
1013
+ handler_list_name = (
1014
+ f"_handlers_{event_type}_{node.line}_{node.column}"
1015
+ )
1016
+
1017
+ # ... [Code similar to lines 907+ to build the list] ...
1018
+ # But wait, lines 907+ append to `body`.
1019
+ # I can do that here! I am in `_add_node`.
1020
+ # I just need to interrupt the `dict` building?
1021
+ # No, I am building lists `dict_keys`, `dict_values`.
1022
+ # I can append statements to `body` *before* the final
1023
+ # `keywords.append(...)` call.
1024
+
1025
+ # [Insert list building logic here]
1026
+ # Since I am replacing a block, I can add statements to body!
1027
+ # Wait, `body` is passed in.
1028
+ # `dict_keys` and `dict_values` are python lists I am building to
1029
+ # *eventually* make an AST node.
1030
+
1031
+ # Let's support single handler first as it covers 99% cases and the
1032
+ # specific bug.
1033
+ # Complex multi-handlers need full porting.
1034
+ pass
1035
+
1036
+ # Add keyword(arg=None, value=dict) for **kwargs
1037
+ keywords = []
1038
+ keywords.append(
1039
+ ast.keyword(
1040
+ arg=None, value=ast.Dict(keys=dict_keys, values=dict_values)
1041
+ )
1042
+ )
1043
+
1044
+ # 4. Handle Slots (Children)
1045
+ # Group children by slot name
1046
+ slots_map: Dict[str, List[TemplateNode]] = {}
1047
+ default_slot_nodes = []
1048
+
1049
+ for child in node.children:
1050
+ # Check for slot="..." attribute on child
1051
+ # Note: child is TemplateNode. attributes dict.
1052
+ # If element:
1053
+ child_slot_name: Optional[str] = None
1054
+ if child.tag and "slot" in child.attributes:
1055
+ child_slot_name = child.attributes["slot"]
1056
+ # Remove slot attribute? Optional but cleaner.
1057
+
1058
+ if child_slot_name:
1059
+ if child_slot_name not in slots_map:
1060
+ slots_map[child_slot_name] = []
1061
+ slots_map[child_slot_name].append(child)
1062
+ else:
1063
+ default_slot_nodes.append(child)
1064
+
1065
+ if default_slot_nodes:
1066
+ slots_map["default"] = default_slot_nodes
1067
+
1068
+ keys: List[Optional[ast.expr]] = []
1069
+ values: List[ast.expr] = []
1070
+
1071
+ for s_name, s_nodes in slots_map.items():
1072
+ slot_var_name = f"_slot_{s_name}_{node.line}_{node.column}".replace(
1073
+ "-", "_"
1074
+ )
1075
+ slot_parts_var = f"{slot_var_name}_parts"
1076
+
1077
+ body.append(
1078
+ ast.Assign(
1079
+ targets=[ast.Name(id=slot_parts_var, ctx=ast.Store())],
1080
+ value=ast.List(elts=[], ctx=ast.Load()),
1081
+ )
1082
+ )
1083
+
1084
+ for s_node in s_nodes:
1085
+ self._add_node(
1086
+ s_node,
1087
+ body,
1088
+ local_vars,
1089
+ bound_var,
1090
+ layout_id,
1091
+ known_methods,
1092
+ known_globals,
1093
+ async_methods,
1094
+ component_map,
1095
+ scope_id,
1096
+ parts_var=slot_parts_var,
1097
+ enable_regions=enable_regions,
1098
+ ) # PASS slot_parts_var
1099
+
1100
+ # Join parts -> slot string
1101
+ # rendered_slot = "".join(slot_parts_var)
1102
+ body.append(
1103
+ ast.Assign(
1104
+ targets=[ast.Name(id=slot_var_name, ctx=ast.Store())],
1105
+ value=ast.Call(
1106
+ func=ast.Attribute(
1107
+ value=ast.Constant(value=""),
1108
+ attr="join",
1109
+ ctx=ast.Load(),
1110
+ ),
1111
+ args=[ast.Name(id=slot_parts_var, ctx=ast.Load())],
1112
+ keywords=[],
1113
+ ),
1114
+ )
1115
+ )
1116
+
1117
+ keys.append(ast.Constant(value=s_name))
1118
+ values.append(ast.Name(id=slot_var_name, ctx=ast.Load()))
1119
+
1120
+ # Add slots=... to keywords
1121
+ if keys:
1122
+ keywords.append(
1123
+ ast.keyword(
1124
+ arg="slots",
1125
+ value=ast.Dict(
1126
+ keys=keys,
1127
+ values=values,
1128
+ ),
1129
+ )
1130
+ )
1131
+
1132
+ # Instantiate component
1133
+ instantiation = ast.Call(
1134
+ func=ast.Name(id=cls_name, ctx=ast.Load()), args=[], keywords=keywords
1135
+ )
1136
+
1137
+ render_call = ast.Call(
1138
+ func=ast.Attribute(
1139
+ value=instantiation, attr="_render_template", ctx=ast.Load()
1140
+ ),
1141
+ args=[],
1142
+ keywords=[],
1143
+ )
1144
+
1145
+ # Append result
1146
+ # parts.append(await ...)
1147
+ append_stmt = ast.Expr(
1148
+ value=ast.Call(
1149
+ func=ast.Attribute(
1150
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
1151
+ attr="append",
1152
+ ctx=ast.Load(),
1153
+ ),
1154
+ args=[ast.Await(value=render_call)],
1155
+ keywords=[],
1156
+ )
1157
+ )
1158
+ self._set_line(append_stmt, node)
1159
+ body.append(append_stmt)
1160
+ return
1161
+
1162
+ # 3. Render Node
1163
+ if node.tag is None:
1164
+ # Text
1165
+ if node.text_content:
1166
+ parts = []
1167
+ if node.is_raw:
1168
+ parts = [node.text_content]
1169
+ else:
1170
+ parts = self.interpolation_parser.parse(
1171
+ node.text_content, node.line, node.column
1172
+ )
1173
+
1174
+ # Optimizations: single string -> simple append
1175
+ if len(parts) == 1 and isinstance(parts[0], str):
1176
+ append_stmt = ast.Expr(
1177
+ value=ast.Call(
1178
+ func=ast.Attribute(
1179
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
1180
+ attr="append",
1181
+ ctx=ast.Load(),
1182
+ ),
1183
+ args=[ast.Constant(value=parts[0])],
1184
+ keywords=[],
1185
+ )
1186
+ )
1187
+ self._set_line(append_stmt, node)
1188
+ body.append(append_stmt)
1189
+ else:
1190
+ # Mixed parts: construct concatenation
1191
+ current_concat = None
1192
+
1193
+ for part in parts:
1194
+ if isinstance(part, str):
1195
+ term = ast.Constant(value=part)
1196
+ else:
1197
+ expr = self._transform_expr(
1198
+ part.expression,
1199
+ local_vars,
1200
+ known_globals,
1201
+ line_offset=part.line,
1202
+ col_offset=part.column,
1203
+ )
1204
+ # Check if this is a raw (unescaped) interpolation
1205
+ is_raw = getattr(part, "is_raw", False)
1206
+ if is_raw:
1207
+ # Raw HTML - no escaping
1208
+ term = ast.Call(
1209
+ func=ast.Name(id="str", ctx=ast.Load()),
1210
+ args=[self._wrap_unwrap_wire(expr)],
1211
+ keywords=[],
1212
+ )
1213
+ else:
1214
+ # Default: escape HTML for XSS prevention
1215
+ term = ast.Call(
1216
+ func=ast.Name(id="escape_html", ctx=ast.Load()),
1217
+ args=[self._wrap_unwrap_wire(expr)],
1218
+ keywords=[],
1219
+ )
1220
+
1221
+ if current_concat is None:
1222
+ current_concat = term
1223
+ else:
1224
+ current_concat = ast.BinOp(
1225
+ left=current_concat, op=ast.Add(), right=term
1226
+ )
1227
+
1228
+ if current_concat:
1229
+ append_stmt = ast.Expr(
1230
+ value=ast.Call(
1231
+ func=ast.Attribute(
1232
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
1233
+ attr="append",
1234
+ ctx=ast.Load(),
1235
+ ),
1236
+ args=[current_concat],
1237
+ keywords=[],
1238
+ )
1239
+ )
1240
+ self._set_line(append_stmt, node)
1241
+ body.append(append_stmt)
1242
+ elif node.special_attributes and isinstance(
1243
+ node.special_attributes[0], InterpolationNode
1244
+ ):
1245
+ # Handle standalone interpolation node from parser splitting
1246
+ interp = node.special_attributes[0]
1247
+ expr = self._transform_expr(
1248
+ interp.expression,
1249
+ local_vars,
1250
+ known_globals,
1251
+ line_offset=interp.line,
1252
+ col_offset=interp.column,
1253
+ )
1254
+ # Check if this is a raw (unescaped) interpolation
1255
+ is_raw = getattr(interp, "is_raw", False)
1256
+ if is_raw:
1257
+ # Raw HTML - no escaping
1258
+ term = ast.Call(
1259
+ func=ast.Name(id="str", ctx=ast.Load()),
1260
+ args=[self._wrap_unwrap_wire(expr)],
1261
+ keywords=[],
1262
+ )
1263
+ else:
1264
+ # Default: escape HTML for XSS prevention
1265
+ term = ast.Call(
1266
+ func=ast.Name(id="escape_html", ctx=ast.Load()),
1267
+ args=[self._wrap_unwrap_wire(expr)],
1268
+ keywords=[],
1269
+ )
1270
+ append_stmt = ast.Expr(
1271
+ value=ast.Call(
1272
+ func=ast.Attribute(
1273
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
1274
+ attr="append",
1275
+ ctx=ast.Load(),
1276
+ ),
1277
+ args=[term],
1278
+ keywords=[],
1279
+ )
1280
+ )
1281
+ self._set_line(append_stmt, node)
1282
+ body.append(append_stmt)
1283
+ pass
1284
+ else:
1285
+ # Element
1286
+ if enable_regions and self._node_is_dynamic(node, known_globals):
1287
+ region_id = self._next_region_id()
1288
+ method_name = f"_render_region_{region_id}"
1289
+ self.region_renderers[region_id] = method_name
1290
+ self.auxiliary_functions.append(
1291
+ self._generate_region_method(
1292
+ node,
1293
+ method_name,
1294
+ region_id,
1295
+ layout_id,
1296
+ known_methods,
1297
+ known_globals,
1298
+ async_methods,
1299
+ component_map,
1300
+ scope_id,
1301
+ implicit_root_source,
1302
+ )
1303
+ )
1304
+
1305
+ append_stmt = ast.Expr(
1306
+ value=ast.Call(
1307
+ func=ast.Attribute(
1308
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
1309
+ attr="append",
1310
+ ctx=ast.Load(),
1311
+ ),
1312
+ args=[
1313
+ ast.Await(
1314
+ value=ast.Call(
1315
+ func=ast.Attribute(
1316
+ value=ast.Name(id="self", ctx=ast.Load()),
1317
+ attr=method_name,
1318
+ ctx=ast.Load(),
1319
+ ),
1320
+ args=[],
1321
+ keywords=[],
1322
+ )
1323
+ )
1324
+ ],
1325
+ keywords=[],
1326
+ )
1327
+ )
1328
+ self._set_line(append_stmt, node)
1329
+ body.append(append_stmt)
1330
+ return
1331
+
1332
+ bindings: Dict[str, ast.expr] = {}
1333
+ new_bound_var = bound_var
1334
+ if region_id:
1335
+ bindings["data-pw-region"] = ast.Constant(value=region_id)
1336
+
1337
+ show_attr = next(
1338
+ (a for a in node.special_attributes if isinstance(a, ShowAttribute)),
1339
+ None,
1340
+ )
1341
+ key_attr = next(
1342
+ (a for a in node.special_attributes if isinstance(a, KeyAttribute)),
1343
+ None,
1344
+ )
1345
+
1346
+ if key_attr:
1347
+ bindings["id"] = ast.Call(
1348
+ func=ast.Name(id="str", ctx=ast.Load()),
1349
+ args=[
1350
+ self._transform_expr(
1351
+ key_attr.expr,
1352
+ local_vars,
1353
+ known_globals,
1354
+ line_offset=node.line,
1355
+ col_offset=node.column,
1356
+ )
1357
+ ],
1358
+ keywords=[],
1359
+ )
1360
+
1361
+ # attrs = {}
1362
+ body.append(
1363
+ ast.Assign(
1364
+ targets=[ast.Name(id="attrs", ctx=ast.Store())],
1365
+ value=ast.Dict(keys=[], values=[]),
1366
+ )
1367
+ )
1368
+
1369
+ # Identify if we need to apply scope
1370
+ # Apply to all elements if scope_id is present
1371
+ # BUT: do not apply to <style> tag itself (unless we want to?), or <script>.
1372
+ # And <slot>.
1373
+ # <style scoped> handling is separate (reshaping content).
1374
+
1375
+ apply_scope = scope_id and node.tag not in (
1376
+ "style",
1377
+ "script",
1378
+ "slot",
1379
+ "template",
1380
+ )
1381
+ if apply_scope:
1382
+ body.append(
1383
+ ast.Assign(
1384
+ targets=[
1385
+ ast.Subscript(
1386
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1387
+ slice=ast.Constant(value=f"data-ph-{scope_id}"),
1388
+ ctx=ast.Store(),
1389
+ )
1390
+ ],
1391
+ value=ast.Constant(value=""),
1392
+ )
1393
+ )
1394
+
1395
+ # Handle <style scoped> content rewriting
1396
+ if node.tag == "style" and scope_id and "scoped" in node.attributes:
1397
+ # Rewrite content
1398
+ if node.children and node.children[0].text_content:
1399
+ original_css = node.children[0].text_content
1400
+
1401
+ # Rewrite CSS with scope ID
1402
+ def rewrite_css(css: str, sid: str) -> str:
1403
+ new_parts = []
1404
+ last_idx = 0
1405
+ in_brace = False
1406
+ for i, char in enumerate(css):
1407
+ if char == "{":
1408
+ if not in_brace:
1409
+ selectors = css[last_idx:i]
1410
+ rewritten_selectors = ",".join(
1411
+ [
1412
+ f"{s.strip()}[data-ph-{sid}]"
1413
+ for s in selectors.split(",")
1414
+ if s.strip()
1415
+ ]
1416
+ )
1417
+ new_parts.append(rewritten_selectors)
1418
+ in_brace = True
1419
+ last_idx = i
1420
+ elif char == "}":
1421
+ if in_brace:
1422
+ new_parts.append(css[last_idx : i + 1])
1423
+ in_brace = False
1424
+ last_idx = i + 1
1425
+
1426
+ new_parts.append(css[last_idx:])
1427
+ return "".join(new_parts)
1428
+
1429
+ rewritten_css = rewrite_css(original_css, scope_id)
1430
+
1431
+ # Generate code to add style to collector:
1432
+ # self._style_collector.add(scope_id, rewritten_css)
1433
+ body.append(
1434
+ ast.Expr(
1435
+ value=ast.Call(
1436
+ func=ast.Attribute(
1437
+ value=ast.Attribute(
1438
+ value=ast.Name(id="self", ctx=ast.Load()),
1439
+ attr="_style_collector",
1440
+ ctx=ast.Load(),
1441
+ ),
1442
+ attr="add",
1443
+ ctx=ast.Load(),
1444
+ ),
1445
+ args=[
1446
+ ast.Constant(value=scope_id),
1447
+ ast.Constant(value=rewritten_css),
1448
+ ],
1449
+ keywords=[],
1450
+ )
1451
+ )
1452
+ )
1453
+
1454
+ # DO NOT output the style node to `parts`.
1455
+ # We just return here because we've handled the "rendering" of this node
1456
+ # (by registering side effect)
1457
+ return
1458
+
1459
+ # Static attrs
1460
+ for k, v in node.attributes.items():
1461
+ if "{" in v and "}" in v:
1462
+ parts = self.interpolation_parser.parse(v, node.line, node.column)
1463
+ current_concat = None
1464
+ for part in parts:
1465
+ if isinstance(part, str):
1466
+ term = ast.Constant(value=part)
1467
+ else:
1468
+ term = ast.Call(
1469
+ func=ast.Name(id="str", ctx=ast.Load()),
1470
+ args=[
1471
+ self._transform_expr(
1472
+ part.expression, local_vars, known_globals
1473
+ )
1474
+ ],
1475
+ keywords=[],
1476
+ )
1477
+ if current_concat is None:
1478
+ current_concat = term
1479
+ else:
1480
+ current_concat = ast.BinOp(
1481
+ left=current_concat, op=ast.Add(), right=term
1482
+ )
1483
+
1484
+ val_expr = (
1485
+ current_concat if current_concat else ast.Constant(value="")
1486
+ )
1487
+ else:
1488
+ val_expr = ast.Constant(value=v)
1489
+
1490
+ body.append(
1491
+ ast.Assign(
1492
+ targets=[
1493
+ ast.Subscript(
1494
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1495
+ slice=ast.Constant(value=k),
1496
+ ctx=ast.Store(),
1497
+ )
1498
+ ],
1499
+ value=val_expr,
1500
+ )
1501
+ )
1502
+
1503
+ # Bindings
1504
+ for k, binding_expr in bindings.items():
1505
+ if k == "checked":
1506
+ # if binding_expr: attrs['checked'] = ""
1507
+ body.append(
1508
+ ast.If(
1509
+ test=binding_expr,
1510
+ body=[
1511
+ ast.Assign(
1512
+ targets=[
1513
+ ast.Subscript(
1514
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1515
+ slice=ast.Constant(value="checked"),
1516
+ ctx=ast.Store(),
1517
+ )
1518
+ ],
1519
+ value=ast.Constant(value=""),
1520
+ )
1521
+ ],
1522
+ orelse=[],
1523
+ )
1524
+ )
1525
+ else:
1526
+ # attrs[k] = str(binding_expr) usually?
1527
+ # If binding_expr is AST expression (from target_var_expr), wrap in str()
1528
+ # If binding_expr is Constant string, direct.
1529
+ # Warning: bindings[k] contains AST nodes now.
1530
+
1531
+ wrapper = binding_expr
1532
+ if not isinstance(binding_expr, ast.Constant):
1533
+ wrapper = ast.Call(
1534
+ func=ast.Name(id="str", ctx=ast.Load()),
1535
+ args=[self._wrap_unwrap_wire(binding_expr)],
1536
+ keywords=[],
1537
+ )
1538
+
1539
+ body.append(
1540
+ ast.Assign(
1541
+ targets=[
1542
+ ast.Subscript(
1543
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1544
+ slice=ast.Constant(value=k),
1545
+ ctx=ast.Store(),
1546
+ )
1547
+ ],
1548
+ value=wrapper,
1549
+ )
1550
+ )
1551
+
1552
+ # Group and generate event attributes (handling multiples via JSON)
1553
+ event_attrs_by_type = defaultdict(list)
1554
+ for attr in node.special_attributes:
1555
+ if isinstance(attr, EventAttribute):
1556
+ event_attrs_by_type[attr.event_type].append(attr)
1557
+
1558
+ for event_type, attrs_list in event_attrs_by_type.items():
1559
+ if len(attrs_list) == 1:
1560
+ # Single handler
1561
+ attr = attrs_list[0]
1562
+ # attrs["data-on-X"] = "handler"
1563
+ body.append(
1564
+ ast.Assign(
1565
+ targets=[
1566
+ ast.Subscript(
1567
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1568
+ slice=ast.Constant(value=f"data-on-{event_type}"),
1569
+ ctx=ast.Store(),
1570
+ )
1571
+ ],
1572
+ value=ast.Constant(value=attr.handler_name),
1573
+ )
1574
+ )
1575
+
1576
+ # Add modifiers if present
1577
+ if attr.modifiers:
1578
+ modifiers_str = " ".join(attr.modifiers)
1579
+ body.append(
1580
+ ast.Assign(
1581
+ targets=[
1582
+ ast.Subscript(
1583
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1584
+ slice=ast.Constant(
1585
+ value=f"data-modifiers-{event_type}"
1586
+ ),
1587
+ ctx=ast.Store(),
1588
+ )
1589
+ ],
1590
+ value=ast.Constant(value=modifiers_str),
1591
+ )
1592
+ )
1593
+
1594
+ # Add args
1595
+ for i, arg_expr in enumerate(attr.args):
1596
+ val = self._transform_expr(
1597
+ arg_expr,
1598
+ local_vars,
1599
+ known_globals,
1600
+ line_offset=node.line,
1601
+ col_offset=node.column,
1602
+ )
1603
+ dump_call = ast.Call(
1604
+ func=ast.Attribute(
1605
+ value=ast.Name(id="json", ctx=ast.Load()),
1606
+ attr="dumps",
1607
+ ctx=ast.Load(),
1608
+ ),
1609
+ args=[val],
1610
+ keywords=[],
1611
+ )
1612
+ body.append(
1613
+ ast.Assign(
1614
+ targets=[
1615
+ ast.Subscript(
1616
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1617
+ slice=ast.Constant(value=f"data-arg-{i}"),
1618
+ ctx=ast.Store(),
1619
+ )
1620
+ ],
1621
+ value=dump_call,
1622
+ )
1623
+ )
1624
+ else:
1625
+ # Multiple handlers - JSON format
1626
+ # _handlers_X = []
1627
+ handler_list_name = f"_handlers_{event_type}"
1628
+ body.append(
1629
+ ast.Assign(
1630
+ targets=[ast.Name(id=handler_list_name, ctx=ast.Store())],
1631
+ value=ast.List(elts=[], ctx=ast.Load()),
1632
+ )
1633
+ )
1634
+
1635
+ all_modifiers = set()
1636
+ for attr in attrs_list:
1637
+ modifiers = attr.modifiers or []
1638
+ all_modifiers.update(modifiers)
1639
+
1640
+ # _h = {"handler": "...", "modifiers": [...]}
1641
+ handler_dict = ast.Dict(
1642
+ keys=[
1643
+ ast.Constant(value="handler"),
1644
+ ast.Constant(value="modifiers"),
1645
+ ],
1646
+ values=[
1647
+ ast.Constant(value=attr.handler_name),
1648
+ ast.List(
1649
+ elts=[ast.Constant(value=m) for m in modifiers],
1650
+ ctx=ast.Load(),
1651
+ ),
1652
+ ],
1653
+ )
1654
+ body.append(
1655
+ ast.Assign(
1656
+ targets=[ast.Name(id="_h", ctx=ast.Store())],
1657
+ value=handler_dict,
1658
+ )
1659
+ )
1660
+
1661
+ if attr.args:
1662
+ # _args = [...]
1663
+ args_list = []
1664
+ for arg_expr in attr.args:
1665
+ val = self._transform_expr(
1666
+ arg_expr,
1667
+ local_vars,
1668
+ known_globals,
1669
+ line_offset=node.line,
1670
+ col_offset=node.column,
1671
+ )
1672
+ args_list.append(val)
1673
+ body.append(
1674
+ ast.Assign(
1675
+ targets=[
1676
+ ast.Subscript(
1677
+ value=ast.Name(id="_h", ctx=ast.Load()),
1678
+ slice=ast.Constant(value="args"),
1679
+ ctx=ast.Store(),
1680
+ )
1681
+ ],
1682
+ value=ast.List(elts=args_list, ctx=ast.Load()),
1683
+ )
1684
+ )
1685
+
1686
+ # _handlers_X.append(_h)
1687
+ body.append(
1688
+ ast.Expr(
1689
+ value=ast.Call(
1690
+ func=ast.Attribute(
1691
+ value=ast.Name(
1692
+ id=handler_list_name, ctx=ast.Load()
1693
+ ),
1694
+ attr="append",
1695
+ ctx=ast.Load(),
1696
+ ),
1697
+ args=[ast.Name(id="_h", ctx=ast.Load())],
1698
+ keywords=[],
1699
+ )
1700
+ )
1701
+ )
1702
+
1703
+ # attrs["data-on-X"] = json.dumps(_handlers_X)
1704
+ body.append(
1705
+ ast.Assign(
1706
+ targets=[
1707
+ ast.Subscript(
1708
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1709
+ slice=ast.Constant(value=f"data-on-{event_type}"),
1710
+ ctx=ast.Store(),
1711
+ )
1712
+ ],
1713
+ value=ast.Call(
1714
+ func=ast.Attribute(
1715
+ value=ast.Name(id="json", ctx=ast.Load()),
1716
+ attr="dumps",
1717
+ ctx=ast.Load(),
1718
+ ),
1719
+ args=[ast.Name(id=handler_list_name, ctx=ast.Load())],
1720
+ keywords=[],
1721
+ ),
1722
+ )
1723
+ )
1724
+
1725
+ if all_modifiers:
1726
+ modifiers_str = " ".join(all_modifiers)
1727
+ body.append(
1728
+ ast.Assign(
1729
+ targets=[
1730
+ ast.Subscript(
1731
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1732
+ slice=ast.Constant(
1733
+ value=f"data-modifiers-{event_type}"
1734
+ ),
1735
+ ctx=ast.Store(),
1736
+ )
1737
+ ],
1738
+ value=ast.Constant(value=modifiers_str),
1739
+ )
1740
+ )
1741
+
1742
+ # Handle other special attributes
1743
+ for attr in node.special_attributes:
1744
+ if isinstance(attr, EventAttribute):
1745
+ continue
1746
+ elif isinstance(attr, ReactiveAttribute):
1747
+ val_expr = self._transform_reactive_expr(
1748
+ attr.expr,
1749
+ local_vars,
1750
+ known_methods,
1751
+ known_globals,
1752
+ async_methods,
1753
+ line_offset=node.line,
1754
+ col_offset=node.column,
1755
+ )
1756
+ val_expr = self._wrap_unwrap_wire(val_expr)
1757
+
1758
+ # _r_val = val_expr
1759
+ body.append(
1760
+ ast.Assign(
1761
+ targets=[ast.Name(id="_r_val", ctx=ast.Store())],
1762
+ value=val_expr,
1763
+ )
1764
+ )
1765
+
1766
+ is_aria = attr.name.lower().startswith("aria-")
1767
+
1768
+ if is_aria:
1769
+ # if _r_val is True: attrs["X"] = "true"
1770
+ # elif _r_val is False: attrs["X"] = "false"
1771
+ # elif _r_val is not None: attrs["X"] = str(_r_val)
1772
+
1773
+ body.append(
1774
+ ast.If(
1775
+ test=ast.Compare(
1776
+ left=ast.Name(id="_r_val", ctx=ast.Load()),
1777
+ ops=[ast.Is()],
1778
+ comparators=[ast.Constant(value=True)],
1779
+ ),
1780
+ body=[
1781
+ ast.Assign(
1782
+ targets=[
1783
+ ast.Subscript(
1784
+ value=ast.Name(
1785
+ id="attrs", ctx=ast.Load()
1786
+ ),
1787
+ slice=ast.Constant(value=attr.name),
1788
+ ctx=ast.Store(),
1789
+ )
1790
+ ],
1791
+ value=ast.Constant(value="true"),
1792
+ )
1793
+ ],
1794
+ orelse=[
1795
+ ast.If(
1796
+ test=ast.Compare(
1797
+ left=ast.Name(id="_r_val", ctx=ast.Load()),
1798
+ ops=[ast.Is()],
1799
+ comparators=[ast.Constant(value=False)],
1800
+ ),
1801
+ body=[
1802
+ ast.Assign(
1803
+ targets=[
1804
+ ast.Subscript(
1805
+ value=ast.Name(
1806
+ id="attrs", ctx=ast.Load()
1807
+ ),
1808
+ slice=ast.Constant(
1809
+ value=attr.name
1810
+ ),
1811
+ ctx=ast.Store(),
1812
+ )
1813
+ ],
1814
+ value=ast.Constant(value="false"),
1815
+ )
1816
+ ],
1817
+ orelse=[
1818
+ ast.If(
1819
+ test=ast.Compare(
1820
+ left=ast.Name(
1821
+ id="_r_val", ctx=ast.Load()
1822
+ ),
1823
+ ops=[ast.IsNot()],
1824
+ comparators=[
1825
+ ast.Constant(value=None)
1826
+ ],
1827
+ ),
1828
+ body=[
1829
+ ast.Assign(
1830
+ targets=[
1831
+ ast.Subscript(
1832
+ value=ast.Name(
1833
+ id="attrs",
1834
+ ctx=ast.Load(),
1835
+ ),
1836
+ slice=ast.Constant(
1837
+ value=attr.name
1838
+ ),
1839
+ ctx=ast.Store(),
1840
+ )
1841
+ ],
1842
+ value=ast.Call(
1843
+ func=ast.Name(
1844
+ id="str", ctx=ast.Load()
1845
+ ),
1846
+ args=[
1847
+ ast.Name(
1848
+ id="_r_val",
1849
+ ctx=ast.Load(),
1850
+ )
1851
+ ],
1852
+ keywords=[],
1853
+ ),
1854
+ )
1855
+ ],
1856
+ orelse=[],
1857
+ )
1858
+ ],
1859
+ )
1860
+ ],
1861
+ )
1862
+ )
1863
+ else:
1864
+ # Default bool behavior
1865
+ # if _r_val is True: attrs["X"] = ""
1866
+ # elif _r_val is not False and _r_val is not None: attrs["X"] = str(_r_val)
1867
+
1868
+ body.append(
1869
+ ast.If(
1870
+ test=ast.Compare(
1871
+ left=ast.Name(id="_r_val", ctx=ast.Load()),
1872
+ ops=[ast.Is()],
1873
+ comparators=[ast.Constant(value=True)],
1874
+ ),
1875
+ body=[
1876
+ ast.Assign(
1877
+ targets=[
1878
+ ast.Subscript(
1879
+ value=ast.Name(
1880
+ id="attrs", ctx=ast.Load()
1881
+ ),
1882
+ slice=ast.Constant(value=attr.name),
1883
+ ctx=ast.Store(),
1884
+ )
1885
+ ],
1886
+ value=ast.Constant(value=""),
1887
+ )
1888
+ ],
1889
+ orelse=[
1890
+ ast.If(
1891
+ test=ast.BoolOp(
1892
+ op=ast.And(),
1893
+ values=[
1894
+ ast.Compare(
1895
+ left=ast.Name(
1896
+ id="_r_val", ctx=ast.Load()
1897
+ ),
1898
+ ops=[ast.IsNot()],
1899
+ comparators=[
1900
+ ast.Constant(value=False)
1901
+ ],
1902
+ ),
1903
+ ast.Compare(
1904
+ left=ast.Name(
1905
+ id="_r_val", ctx=ast.Load()
1906
+ ),
1907
+ ops=[ast.IsNot()],
1908
+ comparators=[
1909
+ ast.Constant(value=None)
1910
+ ],
1911
+ ),
1912
+ ],
1913
+ ),
1914
+ body=[
1915
+ ast.Assign(
1916
+ targets=[
1917
+ ast.Subscript(
1918
+ value=ast.Name(
1919
+ id="attrs", ctx=ast.Load()
1920
+ ),
1921
+ slice=ast.Constant(
1922
+ value=attr.name
1923
+ ),
1924
+ ctx=ast.Store(),
1925
+ )
1926
+ ],
1927
+ value=ast.Call(
1928
+ func=ast.Name(
1929
+ id="str", ctx=ast.Load()
1930
+ ),
1931
+ args=[
1932
+ ast.Name(
1933
+ id="_r_val", ctx=ast.Load()
1934
+ )
1935
+ ],
1936
+ keywords=[],
1937
+ ),
1938
+ )
1939
+ ],
1940
+ orelse=[],
1941
+ )
1942
+ ],
1943
+ )
1944
+ )
1945
+
1946
+ if show_attr:
1947
+ cond = self._transform_expr(
1948
+ show_attr.condition,
1949
+ local_vars,
1950
+ known_globals,
1951
+ line_offset=node.line,
1952
+ col_offset=node.column,
1953
+ )
1954
+ # if not cond: attrs['style'] = ...
1955
+ body.append(
1956
+ ast.If(
1957
+ test=ast.UnaryOp(op=ast.Not(), operand=cond),
1958
+ body=[
1959
+ ast.Assign(
1960
+ targets=[
1961
+ ast.Subscript(
1962
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1963
+ slice=ast.Constant(value="style"),
1964
+ ctx=ast.Store(),
1965
+ )
1966
+ ],
1967
+ value=ast.BinOp(
1968
+ left=ast.Call(
1969
+ func=ast.Attribute(
1970
+ value=ast.Name(id="attrs", ctx=ast.Load()),
1971
+ attr="get",
1972
+ ctx=ast.Load(),
1973
+ ),
1974
+ args=[
1975
+ ast.Constant(value="style"),
1976
+ ast.Constant(value=""),
1977
+ ],
1978
+ keywords=[],
1979
+ ),
1980
+ op=ast.Add(),
1981
+ right=ast.Constant(value="; display: none"),
1982
+ ),
1983
+ )
1984
+ ],
1985
+ orelse=[],
1986
+ )
1987
+ )
1988
+
1989
+ if node.tag.lower() == "option" and bound_var:
1990
+ # if "value" in attrs and str(attrs["value"]) == str(bound_var):
1991
+ # attrs["selected"] = ""
1992
+ # bound_var is AST node here
1993
+ # We need to reuse bound_var AST node carefully (if it's complex,
1994
+ # it might be evaluated multiple times, but usually it's just
1995
+ # Name or Attribute)
1996
+
1997
+ check = ast.If(
1998
+ test=ast.BoolOp(
1999
+ op=ast.And(),
2000
+ values=[
2001
+ ast.Compare(
2002
+ left=ast.Constant(value="value"),
2003
+ ops=[ast.In()],
2004
+ comparators=[ast.Name(id="attrs", ctx=ast.Load())],
2005
+ ),
2006
+ ast.Compare(
2007
+ left=ast.Call(
2008
+ func=ast.Name(id="str", ctx=ast.Load()),
2009
+ args=[
2010
+ ast.Subscript(
2011
+ value=ast.Name(id="attrs", ctx=ast.Load()),
2012
+ slice=ast.Constant(value="value"),
2013
+ ctx=ast.Load(),
2014
+ )
2015
+ ],
2016
+ keywords=[],
2017
+ ),
2018
+ ops=[ast.Eq()],
2019
+ comparators=[
2020
+ ast.Call(
2021
+ func=ast.Name(id="str", ctx=ast.Load()),
2022
+ args=[
2023
+ ast.Constant(value=bound_var)
2024
+ if isinstance(bound_var, str)
2025
+ else bound_var
2026
+ ],
2027
+ keywords=[],
2028
+ )
2029
+ ],
2030
+ ),
2031
+ ],
2032
+ ),
2033
+ body=[
2034
+ ast.Assign(
2035
+ targets=[
2036
+ ast.Subscript(
2037
+ value=ast.Name(id="attrs", ctx=ast.Load()),
2038
+ slice=ast.Constant(value="selected"),
2039
+ ctx=ast.Store(),
2040
+ )
2041
+ ],
2042
+ value=ast.Constant(value=""),
2043
+ )
2044
+ ],
2045
+ orelse=[],
2046
+ )
2047
+ body.append(check)
2048
+
2049
+ # Generate opening tag
2050
+ # header_parts = [] ...
2051
+ # parts.append(f"<{tag}{''.join(header_parts)}>")
2052
+
2053
+ # Determine spread attributes (explicit or implicit)
2054
+ spread_expr = None
2055
+
2056
+ # 1. Explicit spread {**attrs}
2057
+ from pywire.compiler.ast_nodes import SpreadAttribute
2058
+
2059
+ explicit_spread = next(
2060
+ (a for a in node.special_attributes if isinstance(a, SpreadAttribute)),
2061
+ None,
2062
+ )
2063
+ if explicit_spread:
2064
+ # expr is likely 'attrs' or similar
2065
+ # transform it to AST load
2066
+ spread_expr = self._transform_expr(
2067
+ explicit_spread.expr,
2068
+ local_vars,
2069
+ known_globals,
2070
+ line_offset=node.line,
2071
+ col_offset=node.column,
2072
+ )
2073
+
2074
+ # 2. Implicit root injection
2075
+ # Only if no explicit spread AND implicit_root_source is active AND is an element
2076
+ elif implicit_root_source:
2077
+ spread_expr = ast.Attribute(
2078
+ value=ast.Name(id="self", ctx=ast.Load()),
2079
+ attr=implicit_root_source,
2080
+ ctx=ast.Load(),
2081
+ )
2082
+ implicit_root_source = None # Consumed
2083
+
2084
+ # Import render_attrs locally to ensure availability
2085
+ body.append(
2086
+ ast.ImportFrom(
2087
+ module="pywire.runtime.helpers",
2088
+ names=[ast.alias(name="render_attrs", asname=None)],
2089
+ level=0,
2090
+ )
2091
+ )
2092
+
2093
+ # Generate start tag
2094
+ body.append(
2095
+ ast.Expr(
2096
+ value=ast.Call(
2097
+ func=ast.Attribute(
2098
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
2099
+ attr="append",
2100
+ ctx=ast.Load(),
2101
+ ),
2102
+ args=[ast.Constant(value=f"<{node.tag}")],
2103
+ keywords=[],
2104
+ )
2105
+ )
2106
+ )
2107
+
2108
+ # render_attrs(attrs, spread_expr)
2109
+ # attrs is the runtime dict populated with static/dynamic bindings
2110
+ render_call = ast.Call(
2111
+ func=ast.Name(id="render_attrs", ctx=ast.Load()),
2112
+ args=[
2113
+ ast.Name(id="attrs", ctx=ast.Load()),
2114
+ spread_expr if spread_expr else ast.Constant(value=None),
2115
+ ],
2116
+ keywords=[],
2117
+ )
2118
+
2119
+ body.append(
2120
+ ast.Expr(
2121
+ value=ast.Call(
2122
+ func=ast.Attribute(
2123
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
2124
+ attr="append",
2125
+ ctx=ast.Load(),
2126
+ ),
2127
+ args=[render_call],
2128
+ keywords=[],
2129
+ )
2130
+ )
2131
+ )
2132
+
2133
+ # Close opening tag
2134
+ body.append(
2135
+ ast.Expr(
2136
+ value=ast.Call(
2137
+ func=ast.Attribute(
2138
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
2139
+ attr="append",
2140
+ ctx=ast.Load(),
2141
+ ),
2142
+ args=[ast.Constant(value=">")],
2143
+ keywords=[],
2144
+ )
2145
+ )
2146
+ )
2147
+
2148
+ for child in node.children:
2149
+ self._add_node(
2150
+ child,
2151
+ body,
2152
+ local_vars,
2153
+ new_bound_var,
2154
+ layout_id,
2155
+ known_methods,
2156
+ known_globals,
2157
+ async_methods,
2158
+ component_map,
2159
+ scope_id,
2160
+ parts_var=parts_var,
2161
+ implicit_root_source=implicit_root_source,
2162
+ enable_regions=enable_regions,
2163
+ )
2164
+
2165
+ if node.tag.lower() not in self.VOID_ELEMENTS:
2166
+ body.append(
2167
+ ast.Expr(
2168
+ value=ast.Call(
2169
+ func=ast.Attribute(
2170
+ value=ast.Name(id=parts_var, ctx=ast.Load()),
2171
+ attr="append",
2172
+ ctx=ast.Load(),
2173
+ ),
2174
+ args=[ast.Constant(value=f"</{node.tag}>")],
2175
+ keywords=[],
2176
+ )
2177
+ )
2178
+ )