pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__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 (41) hide show
  1. pulse/__init__.py +3 -3
  2. pulse/app.py +34 -20
  3. pulse/code_analysis.py +38 -0
  4. pulse/codegen/codegen.py +18 -50
  5. pulse/codegen/templates/route.py +100 -56
  6. pulse/component.py +24 -6
  7. pulse/components/for_.py +17 -2
  8. pulse/cookies.py +38 -2
  9. pulse/env.py +4 -4
  10. pulse/hooks/init.py +174 -14
  11. pulse/hooks/state.py +105 -0
  12. pulse/js/__init__.py +12 -9
  13. pulse/js/obj.py +79 -0
  14. pulse/js/pulse.py +112 -0
  15. pulse/js/react.py +457 -0
  16. pulse/messages.py +13 -13
  17. pulse/proxy.py +18 -5
  18. pulse/render_session.py +282 -266
  19. pulse/renderer.py +36 -73
  20. pulse/serializer.py +5 -2
  21. pulse/transpiler/__init__.py +13 -0
  22. pulse/transpiler/assets.py +66 -0
  23. pulse/transpiler/builtins.py +0 -20
  24. pulse/transpiler/dynamic_import.py +131 -0
  25. pulse/transpiler/emit_context.py +49 -0
  26. pulse/transpiler/errors.py +29 -11
  27. pulse/transpiler/function.py +36 -5
  28. pulse/transpiler/imports.py +33 -27
  29. pulse/transpiler/js_module.py +73 -20
  30. pulse/transpiler/modules/pulse/tags.py +35 -15
  31. pulse/transpiler/nodes.py +121 -36
  32. pulse/transpiler/py_module.py +1 -1
  33. pulse/transpiler/react_component.py +4 -11
  34. pulse/transpiler/transpiler.py +32 -26
  35. pulse/user_session.py +10 -0
  36. pulse_framework-0.1.55.dist-info/METADATA +196 -0
  37. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
  38. pulse/hooks/states.py +0 -285
  39. pulse_framework-0.1.53.dist-info/METADATA +0 -18
  40. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
  41. {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
@@ -12,6 +12,7 @@ import textwrap
12
12
  import types as pytypes
13
13
  from collections.abc import Callable
14
14
  from dataclasses import dataclass, field
15
+ from pathlib import Path
15
16
  from typing import (
16
17
  Any,
17
18
  Generic,
@@ -57,11 +58,13 @@ CONSTANT_REGISTRY: dict[int, "Constant"] = {}
57
58
 
58
59
  def clear_function_cache() -> None:
59
60
  """Clear function/constant/ref caches and reset the shared ID counters."""
61
+ from pulse.transpiler.assets import clear_asset_registry
60
62
  from pulse.transpiler.imports import clear_import_registry
61
63
 
62
64
  FUNCTION_CACHE.clear()
63
65
  CONSTANT_REGISTRY.clear()
64
66
  clear_import_registry()
67
+ clear_asset_registry()
65
68
  reset_id_counter()
66
69
 
67
70
 
@@ -142,6 +145,10 @@ def _transpile_function_body(
142
145
  # Get and parse source
143
146
  src = getsourcecode(fn)
144
147
  src = textwrap.dedent(src)
148
+ try:
149
+ source_start_line = inspect.getsourcelines(fn)[1]
150
+ except (OSError, TypeError):
151
+ source_start_line = None
145
152
  module = ast.parse(src)
146
153
 
147
154
  # Find the function definition
@@ -152,7 +159,7 @@ def _transpile_function_body(
152
159
  raise TranspileError("No function definition found in source")
153
160
  fndef = fndefs[-1]
154
161
 
155
- # Get filename for error messages
162
+ # Get filename for error messages and source file resolution
156
163
  try:
157
164
  filename = inspect.getfile(fn)
158
165
  except (TypeError, OSError):
@@ -160,7 +167,8 @@ def _transpile_function_body(
160
167
 
161
168
  # Transpile with source context for errors
162
169
  try:
163
- transpiler = Transpiler(fndef, deps, jsx=jsx)
170
+ source_file = Path(filename) if filename else None
171
+ transpiler = Transpiler(fndef, deps, jsx=jsx, source_file=source_file)
164
172
  result = transpiler.transpile()
165
173
  except TranspileError as e:
166
174
  # Re-raise with source context if not already present
@@ -169,6 +177,7 @@ def _transpile_function_body(
169
177
  source=src,
170
178
  filename=filename,
171
179
  func_name=fn.__name__,
180
+ source_start_line=source_start_line,
172
181
  ) from None
173
182
  raise
174
183
 
@@ -347,10 +356,10 @@ class JsxFunction(Expr, Generic[P, R]):
347
356
 
348
357
  @override
349
358
  def transpile_call(
350
- self, args: list[ast.expr], kwargs: dict[str, ast.expr], ctx: Transpiler
359
+ self, args: list[ast.expr], keywords: list[ast.keyword], ctx: Transpiler
351
360
  ) -> Expr:
352
361
  # delegate JSX element building to the generic Jsx wrapper
353
- return Jsx(self).transpile_call(args, kwargs, ctx)
362
+ return Jsx(self).transpile_call(args, keywords, ctx)
354
363
 
355
364
  @override
356
365
  def __call__(self, *args: P.args, **kwargs: P.kwargs) -> R: # pyright: ignore[reportIncompatibleMethodOverride]
@@ -367,18 +376,40 @@ def analyze_code_object(
367
376
  - effective_globals: dict mapping names to their values (includes closure vars)
368
377
  - all_names: set of all names referenced in the code (including nested functions)
369
378
  """
379
+ import dis
380
+
370
381
  code = fn.__code__
371
382
 
372
383
  # Collect all names from code object and nested functions in one pass
373
384
  seen_codes: set[int] = set()
374
385
  all_names: set[str] = set()
375
386
 
387
+ # Opcodes that load names from globals/locals (not attributes)
388
+ GLOBAL_LOAD_OPS = frozenset(
389
+ {
390
+ "LOAD_GLOBAL",
391
+ "LOAD_NAME",
392
+ "STORE_GLOBAL",
393
+ "STORE_NAME",
394
+ "DELETE_GLOBAL",
395
+ "DELETE_NAME",
396
+ }
397
+ )
398
+
376
399
  def walk_code(c: pytypes.CodeType) -> None:
377
400
  if id(c) in seen_codes:
378
401
  return
379
402
  seen_codes.add(id(c))
380
- all_names.update(c.co_names)
403
+
404
+ # Only collect names that are actually loaded as globals, not attributes
405
+ # co_names contains both global names and attribute names, so we need
406
+ # to check the bytecode to distinguish them
407
+ for instr in dis.get_instructions(c):
408
+ if instr.opname in GLOBAL_LOAD_OPS and instr.argval is not None:
409
+ all_names.add(instr.argval)
410
+
381
411
  all_names.update(c.co_freevars) # Include closure variables
412
+
382
413
  for const in c.co_consts:
383
414
  if isinstance(const, pytypes.CodeType):
384
415
  walk_code(const)
@@ -15,6 +15,8 @@ from typing import (
15
15
  from typing import Literal as Lit
16
16
 
17
17
  from pulse.cli.packages import pick_more_specific
18
+ from pulse.transpiler.assets import LocalAsset, register_local_asset
19
+ from pulse.transpiler.errors import TranspileError
18
20
  from pulse.transpiler.id import next_id
19
21
  from pulse.transpiler.nodes import Call, Expr, to_js_identifier
20
22
  from pulse.transpiler.vdom import VDOMNode
@@ -122,10 +124,10 @@ def resolve_local_path(path: str, caller: Path | None = None) -> Path | None:
122
124
  return base_path
123
125
 
124
126
 
125
- # Registry key depends on kind:
126
- # - named: (name, src, "named")
127
- # - default/namespace/side_effect: ("", src, kind) - only one per src
128
- _ImportKey: TypeAlias = tuple[str, str, str]
127
+ # Registry key depends on kind and lazy:
128
+ # - named: (name, src, "named", lazy)
129
+ # - default/namespace/side_effect: ("", src, kind, lazy) - only one per src
130
+ _ImportKey: TypeAlias = tuple[str, str, str, bool]
129
131
  _IMPORT_REGISTRY: dict[_ImportKey, "Import"] = {}
130
132
 
131
133
 
@@ -171,17 +173,22 @@ class Import(Expr):
171
173
  Import("", "./styles.css", kind="side_effect") # Local CSS
172
174
  utils = Import("utils", "./utils", kind="namespace") # Local JS (resolves extension)
173
175
  config = Import("config", "/absolute/path/config", kind="default") # Absolute path
176
+
177
+ # Lazy import (generates factory for code-splitting)
178
+ Chart = Import("Chart", "./Chart", kind="default", lazy=True)
179
+ # Generates: const Chart_1 = () => import("./Chart")
174
180
  """
175
181
 
176
182
  name: str
177
183
  src: str
178
184
  kind: ImportKind
179
185
  is_type: bool
186
+ lazy: bool
180
187
  before: tuple[str, ...]
181
188
  id: str
182
189
  version: str | None = None
183
- source_path: Path | None = (
184
- None # Resolved local file path (for copying during codegen)
190
+ asset: LocalAsset | None = (
191
+ None # Registered local asset (for copying during codegen)
185
192
  )
186
193
 
187
194
  def __init__(
@@ -191,12 +198,17 @@ class Import(Expr):
191
198
  *,
192
199
  kind: ImportKind | None = None,
193
200
  is_type: bool = False,
201
+ lazy: bool = False,
194
202
  version: str | None = None,
195
203
  before: tuple[str, ...] | list[str] = (),
196
204
  _caller_depth: int = 2,
197
205
  ) -> None:
206
+ # Validate: lazy imports cannot be type-only
207
+ if lazy and is_type:
208
+ raise TranspileError("Import cannot be both lazy and type-only")
209
+
198
210
  # Auto-resolve local paths (relative or absolute) to actual files
199
- source_path: Path | None = None
211
+ asset: LocalAsset | None = None
200
212
  import_src = src
201
213
 
202
214
  if is_local_path(src):
@@ -204,7 +216,8 @@ class Import(Expr):
204
216
  caller = caller_file(depth=_caller_depth) if is_relative_path(src) else None
205
217
  resolved = resolve_local_path(src, caller)
206
218
  if resolved is not None:
207
- source_path = resolved
219
+ # Register with unified asset registry
220
+ asset = register_local_asset(resolved)
208
221
  import_src = str(resolved)
209
222
 
210
223
  # Default kind to "named" if not specified
@@ -215,16 +228,16 @@ class Import(Expr):
215
228
  self.src = import_src
216
229
  self.kind = kind
217
230
  self.version = version
218
- self.source_path = source_path
231
+ self.lazy = lazy
232
+ self.asset = asset
219
233
 
220
234
  before_tuple = tuple(before) if isinstance(before, list) else before
221
235
 
222
- # Dedupe key: for named imports use (name, src, "named")
223
- # For default/namespace/side_effect, only one per src: ("", src, kind)
236
+ # Dedupe key: includes lazy flag to keep lazy and eager imports separate
224
237
  if kind == "named":
225
- key: _ImportKey = (name, import_src, "named")
238
+ key: _ImportKey = (name, import_src, "named", lazy)
226
239
  else:
227
- key = ("", import_src, kind)
240
+ key = ("", import_src, kind, lazy)
228
241
 
229
242
  if key in _IMPORT_REGISTRY:
230
243
  existing = _IMPORT_REGISTRY[key]
@@ -260,8 +273,13 @@ class Import(Expr):
260
273
 
261
274
  @property
262
275
  def is_local(self) -> bool:
263
- """Check if this is a local file import (has resolved source_path)."""
264
- return self.source_path is not None
276
+ """Check if this is a local file import (has registered asset)."""
277
+ return self.asset is not None
278
+
279
+ @property
280
+ def is_lazy(self) -> bool:
281
+ """Check if this is a lazy import."""
282
+ return self.lazy
265
283
 
266
284
  # Convenience properties for kind checks
267
285
  @property
@@ -276,18 +294,6 @@ class Import(Expr):
276
294
  def is_side_effect(self) -> bool:
277
295
  return self.kind == "side_effect"
278
296
 
279
- def asset_filename(self) -> str:
280
- """Get the filename for this import when copied to assets folder.
281
-
282
- Uses the import ID for uniqueness and preserves the original extension.
283
- For JS-like files, uses the resolved extension.
284
- """
285
- if self.source_path is None:
286
- raise ValueError("Cannot get asset filename for non-local import")
287
- stem = self.source_path.stem
288
- suffix = self.source_path.suffix
289
- return f"{stem}_{self.id}{suffix}"
290
-
291
297
  # -------------------------------------------------------------------------
292
298
  # Expr.emit: outputs the unique identifier
293
299
  # -------------------------------------------------------------------------
@@ -10,13 +10,15 @@ import ast
10
10
  import inspect
11
11
  import sys
12
12
  from dataclasses import dataclass, field
13
- from typing import Literal, override
13
+ from typing import Any, Literal, override
14
14
 
15
+ from pulse.code_analysis import is_stub_function
15
16
  from pulse.transpiler.errors import TranspileError
16
17
  from pulse.transpiler.imports import Import
17
18
  from pulse.transpiler.nodes import (
18
19
  Expr,
19
20
  Identifier,
21
+ Jsx,
20
22
  Member,
21
23
  New,
22
24
  )
@@ -63,10 +65,10 @@ class Class(Expr):
63
65
  def transpile_call(
64
66
  self,
65
67
  args: list[ast.expr],
66
- kwargs: dict[str, ast.expr],
68
+ keywords: list[ast.keyword],
67
69
  ctx: Transpiler,
68
70
  ) -> Expr:
69
- if kwargs:
71
+ if keywords:
70
72
  raise TranspileError("Keyword arguments not supported in constructor call")
71
73
  return New(self.ctor, [ctx.emit_expr(a) for a in args])
72
74
 
@@ -91,6 +93,7 @@ class JsModule(Expr):
91
93
  - "member": Access as property (e.g., React.useState)
92
94
  - "named_import": Each attribute is a named import (e.g., import { useState } from "react")
93
95
  constructors: Set of names that are constructors (emit with 'new')
96
+ components: Set of names that are components (wrapped with Jsx)
94
97
  """
95
98
 
96
99
  name: str | None
@@ -99,6 +102,7 @@ class JsModule(Expr):
99
102
  kind: Literal["default", "namespace"] = "namespace"
100
103
  values: Literal["member", "named_import"] = "named_import"
101
104
  constructors: frozenset[str] = field(default_factory=frozenset)
105
+ components: frozenset[str] = field(default_factory=frozenset)
102
106
 
103
107
  @override
104
108
  def emit(self, out: list[str]) -> None:
@@ -114,7 +118,7 @@ class JsModule(Expr):
114
118
  def transpile_call(
115
119
  self,
116
120
  args: list[ast.expr],
117
- kwargs: dict[str, ast.expr],
121
+ keywords: list[ast.keyword],
118
122
  ctx: Transpiler,
119
123
  ) -> Expr:
120
124
  label = self.py_name or self.name or "JsModule"
@@ -159,16 +163,17 @@ class JsModule(Expr):
159
163
  import_kind = "default" if self.kind == "default" else "named"
160
164
  return Import(self.name, self.src, kind=import_kind)
161
165
 
162
- def get_value(self, name: str) -> Member | Class | Identifier | Import:
166
+ def get_value(self, name: str) -> Member | Class | Jsx | Identifier | Import:
163
167
  """Get a member of this module as an expression.
164
168
 
165
- For global-identifier modules (name=None): returns Identifier(name) directly (e.g., Set -> Set)
166
- For builtins: returns Member (e.g., Math.sin), or Identifier if name
167
- matches the module name (e.g., Set -> Set, not Set.Set)
169
+ For global-identifier modules (name=None): returns Identifier directly (e.g., Set -> Set)
170
+ These are "virtual" Python modules exposing JS globals - no actual JS module exists.
171
+ For builtin namespaces (src=None): returns Member (e.g., Math.floor)
168
172
  For external modules with "member" style: returns Member (e.g., React.useState)
169
173
  For external modules with "named_import" style: returns a named Import
170
174
 
171
175
  If name is in constructors, returns a Class that emits `new ...(...)`.
176
+ If name is in components, returns a Jsx-wrapped expression.
172
177
  """
173
178
  # Convention: trailing underscore escapes Python keywords (e.g. from_ -> from, is_ -> is).
174
179
  # We keep the original `name` for constructor detection, but emit the JS name.
@@ -176,14 +181,11 @@ class JsModule(Expr):
176
181
 
177
182
  expr: Member | Identifier | Import
178
183
  if self.name is None:
179
- # No module expression: members are just identifiers, not members of a module
184
+ # Virtual module exposing JS globals - members are just identifiers
180
185
  expr = Identifier(js_name)
181
186
  elif self.src is None:
182
- # Builtins: use identifier when name matches module name (Set.Set -> Set)
183
- if name == self.name:
184
- expr = Identifier(js_name)
185
- else:
186
- expr = Member(Identifier(self.name), js_name)
187
+ # Builtin namespace (Math, console, etc.) - members accessed as properties
188
+ expr = Member(Identifier(self.name), js_name)
187
189
  elif self.values == "named_import":
188
190
  expr = Import(js_name, self.src)
189
191
  else:
@@ -191,6 +193,8 @@ class JsModule(Expr):
191
193
 
192
194
  if name in self.constructors:
193
195
  return Class(expr, name=name)
196
+ if name in self.components:
197
+ return Jsx(expr)
194
198
  return expr
195
199
 
196
200
  @override
@@ -229,28 +233,74 @@ class JsModule(Expr):
229
233
  Example (inside pulse/js/set.py):
230
234
  JsModule.register(name=None) # global identifier builtin (no module binding)
231
235
  """
236
+ from pulse.component import Component # Import here to avoid cycles
237
+
232
238
  # Get the calling module from the stack frame
233
239
  frame = inspect.currentframe()
234
240
  assert frame is not None and frame.f_back is not None
235
241
  module_name = frame.f_back.f_globals["__name__"]
236
242
  module = sys.modules[module_name]
237
243
 
238
- # Collect locally defined names and clean up module namespace
244
+ # Collect locally defined names and clean up module namespace.
245
+ # Priority order for categorization:
246
+ # 1. Overrides: real functions (with body), Exprs, real @components → returned directly
247
+ # 2. Components: stub @component functions → wrapped as Jsx(Import(...))
248
+ # 3. Constructors: classes → emitted with 'new' keyword
249
+ # 4. Regular imports: stub functions → standard named imports
239
250
  constructors: set[str] = set()
251
+ components: set[str] = set()
240
252
  local_names: set[str] = set()
253
+ overrides: dict[str, Any] = {}
241
254
 
242
255
  for attr_name in list(vars(module)):
243
- if attr_name in _MODULE_DUNDERS:
256
+ if attr_name in _MODULE_DUNDERS or attr_name.startswith("_"):
244
257
  continue
245
258
 
246
259
  obj = getattr(module, attr_name)
260
+
261
+ # Component-wrapped function - check the raw function's module
262
+ if isinstance(obj, Component):
263
+ raw_fn = obj._raw_fn # pyright: ignore[reportPrivateUsage]
264
+ fn_module = getattr(raw_fn, "__module__", None)
265
+ if fn_module != module_name:
266
+ delattr(module, attr_name)
267
+ continue
268
+ if is_stub_function(raw_fn):
269
+ # Stub component → becomes Jsx(Import(...))
270
+ components.add(attr_name)
271
+ local_names.add(attr_name)
272
+ else:
273
+ # Real component → preserve as override
274
+ overrides[attr_name] = obj
275
+ delattr(module, attr_name)
276
+ continue
277
+
247
278
  is_local = not hasattr(obj, "__module__") or obj.__module__ == module_name
279
+ if not is_local:
280
+ delattr(module, attr_name)
281
+ continue
248
282
 
249
- if is_local and not attr_name.startswith("_"):
283
+ # Existing Expr (e.g., manually created Jsx)
284
+ if isinstance(obj, Expr):
285
+ overrides[attr_name] = obj
286
+ delattr(module, attr_name)
287
+ continue
288
+
289
+ # Real function with body → preserve as override
290
+ if inspect.isfunction(obj) and not is_stub_function(obj):
291
+ overrides[attr_name] = obj
292
+ delattr(module, attr_name)
293
+ continue
294
+
295
+ # Class → constructor (existing behavior)
296
+ if inspect.isclass(obj):
297
+ constructors.add(attr_name)
250
298
  local_names.add(attr_name)
251
- if inspect.isclass(obj):
252
- constructors.add(attr_name)
299
+ delattr(module, attr_name)
300
+ continue
253
301
 
302
+ # Stub function → regular import (existing behavior)
303
+ local_names.add(attr_name)
254
304
  delattr(module, attr_name)
255
305
 
256
306
  # Add annotated constants to local_names
@@ -271,11 +321,14 @@ class JsModule(Expr):
271
321
  kind=kind,
272
322
  values=values,
273
323
  constructors=frozenset(constructors),
324
+ components=frozenset(components),
274
325
  )
275
326
  # Register the module object itself so `import pulse.js.math as Math` resolves via EXPR_REGISTRY.
276
327
  Expr.register(module, js_module)
277
328
 
278
- def __getattr__(name: str) -> Member | Class | Identifier | Import:
329
+ def __getattr__(name: str) -> Member | Class | Jsx | Identifier | Import | Any:
330
+ if name in overrides:
331
+ return overrides[name]
279
332
  if name.startswith("_") or name not in local_names:
280
333
  raise AttributeError(name)
281
334
  return js_module.get_value(name)
@@ -16,7 +16,16 @@ import ast
16
16
  from dataclasses import dataclass
17
17
  from typing import Any, final, override
18
18
 
19
- from pulse.transpiler.nodes import Element, Expr, Literal, Node, Prop
19
+ from pulse.components.for_ import emit_for
20
+ from pulse.transpiler.nodes import (
21
+ Element,
22
+ Expr,
23
+ Literal,
24
+ Node,
25
+ Prop,
26
+ Spread,
27
+ spread_dict,
28
+ )
20
29
  from pulse.transpiler.py_module import PyModule
21
30
  from pulse.transpiler.transpiler import Transpiler
22
31
  from pulse.transpiler.vdom import VDOMNode
@@ -44,30 +53,38 @@ class TagExpr(Expr):
44
53
  def transpile_call(
45
54
  self,
46
55
  args: list[ast.expr],
47
- kwargs: dict[str, ast.expr],
56
+ keywords: list[ast.keyword],
48
57
  ctx: Transpiler,
49
58
  ) -> Expr:
50
- """Handle tag calls: positional args are children, kwargs are props."""
59
+ """Handle tag calls: positional args are children, kwargs are props.
60
+
61
+ Spread (**expr) is supported for prop spreading.
62
+ """
51
63
  # Build children from positional args
52
64
  children: list[Node] = []
53
65
  for a in args:
54
66
  children.append(ctx.emit_expr(a))
55
67
 
56
68
  # Build props from kwargs
57
- props: dict[str, Prop] = {}
69
+ props: list[tuple[str, Prop] | Spread] = []
58
70
  key: str | Expr | None = None
59
- for k, v in kwargs.items():
60
- prop_value = ctx.emit_expr(v)
61
- if k == "key":
62
- # Accept any expression as key for transpilation
63
- if isinstance(prop_value, Literal) and isinstance(
64
- prop_value.value, str
65
- ):
66
- key = prop_value.value # Optimize string literals
67
- else:
68
- key = prop_value # Keep as expression
71
+ for kw in keywords:
72
+ if kw.arg is None:
73
+ # **spread syntax
74
+ props.append(spread_dict(ctx.emit_expr(kw.value)))
69
75
  else:
70
- props[k] = prop_value
76
+ k = kw.arg
77
+ prop_value = ctx.emit_expr(kw.value)
78
+ if k == "key":
79
+ # Accept any expression as key for transpilation
80
+ if isinstance(prop_value, Literal) and isinstance(
81
+ prop_value.value, str
82
+ ):
83
+ key = prop_value.value # Optimize string literals
84
+ else:
85
+ key = prop_value # Keep as expression
86
+ else:
87
+ props.append((k, prop_value))
71
88
 
72
89
  return Element(
73
90
  tag=self.tag,
@@ -229,3 +246,6 @@ class PulseTags(PyModule):
229
246
 
230
247
  # React fragment
231
248
  fragment = TagExpr("")
249
+
250
+ # For component - maps to array.map()
251
+ For = emit_for