pulse-framework 0.1.51__py3-none-any.whl → 0.1.53__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 (84) hide show
  1. pulse/__init__.py +542 -562
  2. pulse/_examples.py +29 -0
  3. pulse/app.py +0 -14
  4. pulse/cli/cmd.py +96 -80
  5. pulse/cli/dependencies.py +10 -41
  6. pulse/cli/folder_lock.py +3 -3
  7. pulse/cli/helpers.py +40 -67
  8. pulse/cli/logging.py +102 -0
  9. pulse/cli/packages.py +16 -0
  10. pulse/cli/processes.py +40 -23
  11. pulse/codegen/codegen.py +70 -35
  12. pulse/codegen/js.py +2 -4
  13. pulse/codegen/templates/route.py +94 -146
  14. pulse/component.py +115 -0
  15. pulse/components/for_.py +1 -1
  16. pulse/components/if_.py +1 -1
  17. pulse/components/react_router.py +16 -22
  18. pulse/{html → dom}/events.py +1 -1
  19. pulse/{html → dom}/props.py +6 -6
  20. pulse/{html → dom}/tags.py +11 -11
  21. pulse/dom/tags.pyi +480 -0
  22. pulse/form.py +7 -6
  23. pulse/hooks/init.py +1 -13
  24. pulse/js/__init__.py +37 -41
  25. pulse/js/__init__.pyi +22 -2
  26. pulse/js/_types.py +5 -3
  27. pulse/js/array.py +121 -38
  28. pulse/js/console.py +9 -9
  29. pulse/js/date.py +22 -19
  30. pulse/js/document.py +8 -4
  31. pulse/js/error.py +12 -14
  32. pulse/js/json.py +4 -3
  33. pulse/js/map.py +17 -7
  34. pulse/js/math.py +2 -2
  35. pulse/js/navigator.py +4 -4
  36. pulse/js/number.py +8 -8
  37. pulse/js/object.py +9 -13
  38. pulse/js/promise.py +25 -9
  39. pulse/js/regexp.py +6 -6
  40. pulse/js/set.py +20 -8
  41. pulse/js/string.py +7 -7
  42. pulse/js/weakmap.py +6 -6
  43. pulse/js/weakset.py +6 -6
  44. pulse/js/window.py +17 -14
  45. pulse/messages.py +1 -4
  46. pulse/react_component.py +3 -1001
  47. pulse/render_session.py +74 -66
  48. pulse/renderer.py +311 -238
  49. pulse/routing.py +1 -10
  50. pulse/transpiler/__init__.py +84 -114
  51. pulse/transpiler/builtins.py +661 -343
  52. pulse/transpiler/errors.py +78 -2
  53. pulse/transpiler/function.py +463 -133
  54. pulse/transpiler/id.py +18 -0
  55. pulse/transpiler/imports.py +230 -325
  56. pulse/transpiler/js_module.py +218 -209
  57. pulse/transpiler/modules/__init__.py +16 -13
  58. pulse/transpiler/modules/asyncio.py +45 -26
  59. pulse/transpiler/modules/json.py +12 -8
  60. pulse/transpiler/modules/math.py +161 -216
  61. pulse/transpiler/modules/pulse/__init__.py +5 -0
  62. pulse/transpiler/modules/pulse/tags.py +231 -0
  63. pulse/transpiler/modules/typing.py +33 -28
  64. pulse/transpiler/nodes.py +1607 -923
  65. pulse/transpiler/py_module.py +118 -95
  66. pulse/transpiler/react_component.py +51 -0
  67. pulse/transpiler/transpiler.py +593 -437
  68. pulse/transpiler/vdom.py +255 -0
  69. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/METADATA +1 -1
  70. pulse_framework-0.1.53.dist-info/RECORD +120 -0
  71. pulse/html/tags.pyi +0 -470
  72. pulse/transpiler/constants.py +0 -110
  73. pulse/transpiler/context.py +0 -26
  74. pulse/transpiler/ids.py +0 -16
  75. pulse/transpiler/modules/re.py +0 -466
  76. pulse/transpiler/modules/tags.py +0 -268
  77. pulse/transpiler/utils.py +0 -4
  78. pulse/vdom.py +0 -599
  79. pulse_framework-0.1.51.dist-info/RECORD +0 -119
  80. /pulse/{html → dom}/__init__.py +0 -0
  81. /pulse/{html → dom}/elements.py +0 -0
  82. /pulse/{html → dom}/svg.py +0 -0
  83. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/WHEEL +0 -0
  84. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.53.dist-info}/entry_points.txt +0 -0
pulse/transpiler/nodes.py CHANGED
@@ -1,1216 +1,1900 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import ast
4
+ import datetime as dt
5
+ import string
6
+ import warnings
4
7
  from abc import ABC, abstractmethod
5
- from collections.abc import Callable, Sequence
6
- from typing import Any, ClassVar, TypeVar, cast, overload, override
7
-
8
- from pulse.transpiler.context import is_interpreted_mode
9
- from pulse.transpiler.errors import JSCompilationError
10
-
11
- # Global registry: id(value) -> JSExpr
12
- # Used by JSExpr.of() to resolve registered Python values
13
- JSEXPR_REGISTRY: dict[int, JSExpr] = {}
14
-
15
- ALLOWED_BINOPS: dict[type[ast.operator], str] = {
16
- ast.Add: "+",
17
- ast.Sub: "-",
18
- ast.Mult: "*",
19
- ast.Div: "/",
20
- ast.Mod: "%",
21
- ast.Pow: "**",
22
- }
8
+ from collections.abc import Callable, Iterable, Sequence
9
+ from dataclasses import dataclass, field
10
+ from inspect import isfunction, signature
11
+ from typing import (
12
+ TYPE_CHECKING,
13
+ Any,
14
+ Generic,
15
+ Protocol,
16
+ TypeAlias,
17
+ TypeVar,
18
+ cast,
19
+ overload,
20
+ override,
21
+ )
22
+ from typing import Literal as Lit
23
+
24
+ from pulse.env import env
25
+ from pulse.transpiler.errors import TranspileError
26
+ from pulse.transpiler.vdom import VDOMNode
27
+
28
+ if TYPE_CHECKING:
29
+ from pulse.transpiler.transpiler import Transpiler
30
+
31
+ _T = TypeVar("_T")
32
+ Primitive: TypeAlias = bool | int | float | str | dt.datetime | None
33
+
34
+ _JS_IDENTIFIER_START = set(string.ascii_letters + "_")
35
+ _JS_IDENTIFIER_CONTINUE = set(string.ascii_letters + string.digits + "_")
36
+
37
+
38
+ def to_js_identifier(name: str) -> str:
39
+ """Normalize a string to a JS-compatible identifier."""
40
+ if not name:
41
+ return "_"
42
+ out: list[str] = []
43
+ for ch in name:
44
+ out.append(ch if ch in _JS_IDENTIFIER_CONTINUE else "_")
45
+ if not out or out[0] not in _JS_IDENTIFIER_START:
46
+ out.insert(0, "_")
47
+ return "".join(out)
48
+
49
+
50
+ # =============================================================================
51
+ # Global registries
52
+ # =============================================================================
53
+
54
+ # Global registry: id(value) -> Expr
55
+ # Used by Expr.of() to resolve registered Python values
56
+ EXPR_REGISTRY: dict[int, "Expr"] = {}
57
+
58
+
59
+ # =============================================================================
60
+ # Base classes
61
+ # =============================================================================
62
+ class Expr(ABC):
63
+ """Base class for expression nodes.
64
+
65
+ Provides hooks for custom transpilation behavior:
66
+ - transpile_call: customize behavior when called as a function
67
+ - transpile_getattr: customize attribute access
68
+ - transpile_subscript: customize subscript access
69
+
70
+ And serialization for client-side rendering:
71
+ - render: serialize to dict for client renderer (stub for now)
72
+ """
23
73
 
24
- ALLOWED_UNOPS: dict[type[ast.unaryop], str] = {
25
- ast.UAdd: "+",
26
- ast.USub: "-",
27
- ast.Not: "!",
28
- }
74
+ __slots__: tuple[str, ...] = ()
29
75
 
30
- ALLOWED_CMPOPS: dict[type[ast.cmpop], str] = {
31
- ast.Eq: "===",
32
- ast.NotEq: "!==",
33
- ast.Lt: "<",
34
- ast.LtE: "<=",
35
- ast.Gt: ">",
36
- ast.GtE: ">=",
37
- }
76
+ @abstractmethod
77
+ def emit(self, out: list[str]) -> None:
78
+ """Emit this expression as JavaScript/JSX code into the output buffer."""
38
79
 
80
+ def precedence(self) -> int:
81
+ """Operator precedence (higher = binds tighter). Default: primary (20)."""
82
+ return 20
39
83
 
40
- ###############################################################################
41
- # JS AST
42
- ###############################################################################
84
+ # -------------------------------------------------------------------------
85
+ # Transpilation hooks (override to customize behavior)
86
+ # -------------------------------------------------------------------------
43
87
 
88
+ def transpile_call(
89
+ self,
90
+ args: list[ast.expr],
91
+ kwargs: dict[str, ast.expr],
92
+ ctx: Transpiler,
93
+ ) -> Expr:
94
+ """Called when this expression is used as a function: expr(args).
44
95
 
45
- class JSNode(ABC):
46
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
96
+ Override to customize call behavior.
97
+ Default raises - most expressions are not callable.
47
98
 
48
- @abstractmethod
49
- def emit(self) -> str:
50
- raise NotImplementedError
99
+ Args and kwargs are raw Python `ast.expr` nodes (not yet transpiled).
100
+ Use ctx.emit_expr() to convert them to Expr as needed.
101
+ """
102
+ if kwargs:
103
+ raise TranspileError("Keyword arguments not yet supported in v2 transpiler")
104
+ return Call(self, [ctx.emit_expr(a) for a in args])
51
105
 
106
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
107
+ """Called when an attribute is accessed: expr.attr.
52
108
 
53
- class JSExpr(JSNode, ABC):
54
- """Base class for JavaScript expressions.
109
+ Override to customize attribute access.
110
+ Default returns Member(self, attr).
111
+ """
112
+ return Member(self, attr)
55
113
 
56
- Subclasses can override emit_call, emit_subscript, and emit_getattr to
57
- customize how the expression behaves when called, indexed, or accessed.
58
- This enables extensibility for things like JSX elements.
59
- """
114
+ def transpile_subscript(self, key: ast.expr, ctx: Transpiler) -> Expr:
115
+ """Called when subscripted: expr[key].
60
116
 
61
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
117
+ Override to customize subscript behavior.
118
+ Default returns Subscript(self, emitted_key).
119
+ """
120
+ if isinstance(key, ast.Tuple):
121
+ raise TranspileError(
122
+ "Multiple indices not supported in subscript", node=key
123
+ )
124
+ return Subscript(self, ctx.emit_expr(key))
62
125
 
63
- # Set to True for expressions that emit JSX (should not be wrapped in {})
64
- is_jsx: ClassVar[bool] = False
126
+ # -------------------------------------------------------------------------
127
+ # Serialization for client-side rendering
128
+ # -------------------------------------------------------------------------
65
129
 
66
- # Set to True for expressions that have primary precedence (identifiers, literals, etc.)
67
- # Used by expr_precedence to determine if parenthesization is needed
68
- is_primary: ClassVar[bool] = False
130
+ @abstractmethod
131
+ def render(self) -> VDOMNode:
132
+ """Serialize this expression node for client-side rendering.
69
133
 
70
- @classmethod
71
- def of(cls, value: Any) -> JSExpr:
72
- """Convert a Python value to a JSExpr.
134
+ Returns a VDOMNode (primitive or dict) that can be JSON-serialized and
135
+ evaluated on the client. Override in each concrete Expr subclass.
73
136
 
74
- Resolution order:
75
- 1. Already a JSExpr: returned as-is
76
- 2. Registered in JSEXPR_REGISTRY: return the registered expr
77
- 3. Primitives: str->JSString, int/float->JSNumber, bool->JSBoolean, None->JSNull
78
- 4. Collections: list/tuple->JSArray, dict->JSObjectExpr (recursively converted)
137
+ Raises TypeError for nodes that cannot be serialized (e.g., Transformer).
79
138
  """
80
- # Already a JSExpr
81
- if isinstance(value, JSExpr):
82
- return value
83
139
 
84
- # Check registry (for modules, functions, etc.)
85
- if (expr := JSEXPR_REGISTRY.get(id(value))) is not None:
86
- return expr
140
+ # -------------------------------------------------------------------------
141
+ # Python dunder methods for natural syntax in @javascript functions
142
+ # These return Expr nodes that represent the operations at transpile time.
143
+ # -------------------------------------------------------------------------
87
144
 
88
- # Primitives
89
- if isinstance(value, str):
90
- return JSString(value)
91
- if isinstance(
92
- value, bool
93
- ): # Must check before int since bool is subclass of int
94
- return JSBoolean(value)
95
- if isinstance(value, (int, float)):
96
- return JSNumber(value)
97
- if value is None:
98
- return JSNull()
145
+ def __call__(self, *args: object, **kwargs: object) -> "Call":
146
+ """Allow calling Expr objects in Python code.
99
147
 
100
- # Collections
101
- if isinstance(value, (list, tuple)):
102
- return JSArray([cls.of(v) for v in value])
103
- if isinstance(value, dict):
104
- props = [JSProp(JSString(str(k)), cls.of(v)) for k, v in value.items()] # pyright: ignore[reportUnknownArgumentType]
105
- return JSObjectExpr(props)
148
+ Returns a Call expression. Subclasses may override to return more
149
+ specific types (e.g., Element for JSX components).
150
+ """
151
+ return Call(self, [Expr.of(a) for a in args])
106
152
 
107
- raise TypeError(f"Cannot convert {type(value).__name__} to JSExpr")
153
+ def __getitem__(self, key: object) -> "Subscript":
154
+ """Allow subscript access on Expr objects in Python code.
108
155
 
109
- @classmethod
110
- def register(cls, value: Any, expr: JSExpr | Callable[..., JSExpr]) -> None:
111
- """Register a Python value for conversion via JSExpr.of().
156
+ Returns a Subscript expression for type checking.
157
+ """
158
+ return Subscript(self, Expr.of(key))
112
159
 
113
- Args:
114
- value: The Python object to register (function, constant, etc.)
115
- expr: Either a JSExpr or a Callable[..., JSExpr] (will be wrapped in JSTransformer)
160
+ def __getattr__(self, attr: str) -> "Member":
161
+ """Allow attribute access on Expr objects in Python code.
162
+
163
+ Returns a Member expression for type checking.
116
164
  """
117
- if callable(expr) and not isinstance(expr, JSExpr):
118
- expr = JSTransformer(expr)
119
- JSEXPR_REGISTRY[id(value)] = expr
165
+ return Member(self, attr)
120
166
 
121
- def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
122
- """Called when this expression is used as a function: expr(args).
167
+ def __add__(self, other: object) -> "Binary":
168
+ """Allow + operator on Expr objects."""
169
+ return Binary(self, "+", Expr.of(other))
123
170
 
124
- Override to customize call behavior. Default converts args/kwargs to
125
- JSExpr via JSExpr.of() and emits JSCall(self, args).
126
- Rejects keyword arguments by default.
171
+ def __sub__(self, other: object) -> "Binary":
172
+ """Allow - operator on Expr objects."""
173
+ return Binary(self, "-", Expr.of(other))
127
174
 
128
- Args receive raw Python values. Use JSExpr.of() to convert as needed.
175
+ def __mul__(self, other: object) -> "Binary":
176
+ """Allow * operator on Expr objects."""
177
+ return Binary(self, "*", Expr.of(other))
129
178
 
130
- The kwargs dict maps prop names to values:
131
- - "propName" -> value for named kwargs
132
- - "$spread{N}" -> JSSpread(expr) for **spread kwargs (already JSExpr)
179
+ def __truediv__(self, other: object) -> "Binary":
180
+ """Allow / operator on Expr objects."""
181
+ return Binary(self, "/", Expr.of(other))
133
182
 
134
- Dict order is preserved, so iteration order matches source order.
135
- """
136
- if kwargs:
137
- raise JSCompilationError(
138
- "Keyword arguments not supported in default function call"
139
- )
140
- return JSCall(self, [JSExpr.of(a) for a in args])
183
+ def __mod__(self, other: object) -> "Binary":
184
+ """Allow % operator on Expr objects."""
185
+ return Binary(self, "%", Expr.of(other))
141
186
 
142
- def emit_subscript(self, indices: list[Any]) -> JSExpr:
143
- """Called when this expression is indexed: expr[a, b, c].
187
+ def __and__(self, other: object) -> "Binary":
188
+ """Allow & operator on Expr objects (maps to &&)."""
189
+ return Binary(self, "&&", Expr.of(other))
144
190
 
145
- Override to customize subscript behavior. Default requires single index
146
- and emits JSSubscript(self, index).
191
+ def __or__(self, other: object) -> "Binary":
192
+ """Allow | operator on Expr objects (maps to ||)."""
193
+ return Binary(self, "||", Expr.of(other))
147
194
 
148
- Args receive raw Python values. Use JSExpr.of() to convert as needed.
149
- """
150
- if len(indices) != 1:
151
- raise JSCompilationError("Multiple indices not supported in subscript")
152
- return JSSubscript(self, JSExpr.of(indices[0]))
195
+ def __neg__(self) -> "Unary":
196
+ """Allow unary - operator on Expr objects."""
197
+ return Unary("-", self)
153
198
 
154
- def emit_getattr(self, attr: str) -> JSExpr:
155
- """Called when an attribute is accessed: expr.attr.
199
+ def __pos__(self) -> "Unary":
200
+ """Allow unary + operator on Expr objects."""
201
+ return Unary("+", self)
156
202
 
157
- Override to customize attribute access. Default emits JSMember(self, attr).
158
- """
159
- return JSMember(self, attr)
203
+ def __invert__(self) -> "Unary":
204
+ """Allow ~ operator on Expr objects (maps to !)."""
205
+ return Unary("!", self)
160
206
 
161
- def __getattr__(self, attr: str) -> JSExpr:
162
- """Support attribute access at Python runtime.
207
+ # -------------------------------------------------------------------------
208
+ # Type casting and wrapper methods
209
+ # -------------------------------------------------------------------------
163
210
 
164
- Allows: expr.attr where expr is any JSExpr.
165
- Delegates to emit_getattr for transpilation.
211
+ def as_(self, typ_: "_T | type[_T]") -> "_T":
212
+ """Cast this expression to a type or use as a decorator.
213
+
214
+ Usage as decorator:
215
+ @Import(...).as_
216
+ def fn(): ...
217
+
218
+ Usage for type casting:
219
+ clsx = Import(...).as_(Callable[[str, ...], str])
220
+
221
+ If typ_ is a user-defined callable (function or lambda),
222
+ wraps the expression in a Signature node that stores the callable's
223
+ signature for type introspection.
166
224
  """
167
- return self.emit_getattr(attr)
225
+ # Only wrap for user-defined functions (lambdas, def functions)
226
+ # Skip for types (str, int, etc.) used as type annotations
227
+ if isfunction(typ_):
228
+ try:
229
+ sig = signature(typ_)
230
+ return cast("_T", Signature(self, sig))
231
+ except (ValueError, TypeError):
232
+ # Signature not available (e.g., for built-ins), return self
233
+ pass
234
+
235
+ return cast("_T", self)
168
236
 
169
- def __call__(self, *args: Any, **kwargs: Any) -> JSExpr:
170
- """Support function calls at Python runtime.
237
+ def jsx(self) -> "Jsx":
238
+ """Wrap this expression as a JSX component.
171
239
 
172
- Allows: expr(*args, **kwargs) where expr is any JSExpr.
173
- Delegates to emit_call for transpilation.
240
+ When called in transpiled code, produces Element(tag=self, ...).
174
241
  """
175
- return self.emit_call(list(args), kwargs)
242
+ return Jsx(self)
176
243
 
177
- def __getitem__(self, key: Any) -> JSExpr:
178
- """Support subscript access at Python runtime.
244
+ # -------------------------------------------------------------------------
245
+ # Registry for Python value -> Expr mapping
246
+ # -------------------------------------------------------------------------
179
247
 
180
- Allows: expr[key] where expr is any JSExpr.
181
- Delegates to emit_subscript for transpilation.
248
+ @staticmethod
249
+ def of(value: Any) -> Expr:
250
+ """Convert a Python value to an Expr.
251
+
252
+ Resolution order:
253
+ 1. Already an Expr: returned as-is
254
+ 2. Registered in EXPR_REGISTRY: return the registered expr
255
+ 3. Primitives: str/int/float -> Literal, bool -> Literal, None -> Literal(None)
256
+ 4. Collections: list/tuple -> Array, dict -> Object (recursively converted)
257
+ 5. set -> Call(Identifier("Set"), [Array(...)])
258
+
259
+ Raises TypeError for unconvertible values.
182
260
  """
183
- return self.emit_subscript([key])
261
+ # Already an Expr
262
+ if isinstance(value, Expr):
263
+ return value
184
264
 
265
+ # Check registry (for modules, functions, etc.)
266
+ if (expr := EXPR_REGISTRY.get(id(value))) is not None:
267
+ return expr
185
268
 
186
- class JSStmt(JSNode, ABC):
187
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
269
+ # Primitives - must check bool before int since bool is subclass of int
270
+ if isinstance(value, bool):
271
+ return Literal(value)
272
+ if isinstance(value, (int, float, str)):
273
+ return Literal(value)
274
+ if value is None:
275
+ return Literal(None)
188
276
 
277
+ # Collections
278
+ if isinstance(value, (list, tuple)):
279
+ return Array([Expr.of(v) for v in value])
280
+ if isinstance(value, dict):
281
+ props = [(str(k), Expr.of(v)) for k, v in value.items()] # pyright: ignore[reportUnknownArgumentType]
282
+ return Object(props)
283
+ if isinstance(value, set):
284
+ # new Set([...])
285
+ return New(Identifier("Set"), [Array([Expr.of(v) for v in value])])
189
286
 
190
- class JSIdentifier(JSExpr):
191
- __slots__ = ("name",) # pyright: ignore[reportUnannotatedClassAttribute]
192
- is_primary: ClassVar[bool] = True
193
- name: str
287
+ raise TypeError(f"Cannot convert {type(value).__name__} to Expr")
194
288
 
195
- def __init__(self, name: str):
196
- self.name = name
289
+ @staticmethod
290
+ def register(value: Any, expr: Expr | Callable[..., Expr]) -> None:
291
+ """Register a Python value for conversion via Expr.of().
197
292
 
198
- @override
199
- def emit(self) -> str:
200
- return self.name
293
+ Args:
294
+ value: The Python object to register (function, constant, etc.)
295
+ expr: Either an Expr or a Callable[..., Expr] (will be wrapped in Transformer)
296
+ """
297
+ if callable(expr) and not isinstance(expr, Expr):
298
+ expr = Transformer(expr)
299
+ EXPR_REGISTRY[id(value)] = expr
201
300
 
202
301
 
203
- class JSString(JSExpr):
204
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
205
- is_primary: ClassVar[bool] = True
206
- value: str
302
+ class Stmt(ABC):
303
+ """Base class for statement nodes."""
207
304
 
208
- def __init__(self, value: str):
209
- self.value = value
305
+ __slots__: tuple[str, ...] = ()
210
306
 
211
- @override
212
- def emit(self) -> str:
213
- s = self.value
214
- # Escape for double-quoted JS string literals
215
- s = (
216
- s.replace("\\", "\\\\")
217
- .replace('"', '\\"')
218
- .replace("\n", "\\n")
219
- .replace("\r", "\\r")
220
- .replace("\t", "\\t")
221
- .replace("\b", "\\b")
222
- .replace("\f", "\\f")
223
- .replace("\v", "\\v")
224
- .replace("\x00", "\\x00")
225
- .replace("\u2028", "\\u2028")
226
- .replace("\u2029", "\\u2029")
227
- )
228
- return f'"{s}"'
307
+ @abstractmethod
308
+ def emit(self, out: list[str]) -> None:
309
+ """Emit this statement as JavaScript code into the output buffer."""
229
310
 
230
311
 
231
- class JSNumber(JSExpr):
232
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
233
- is_primary: ClassVar[bool] = True
234
- value: int | float
312
+ # =============================================================================
313
+ # Data Nodes
314
+ # =============================================================================
235
315
 
236
- def __init__(self, value: int | float):
237
- self.value = value
238
316
 
239
- @override
240
- def emit(self) -> str:
241
- return str(self.value)
317
+ class ExprWrapper(Expr):
318
+ """Base class for Expr wrappers that delegate to an underlying expression.
319
+
320
+ Subclasses must define an `expr` attribute (via __slots__ or dataclass).
321
+ All Expr methods delegate to self.expr by default. Override specific
322
+ methods to customize behavior.
323
+ """
242
324
 
325
+ __slots__: tuple[str, ...] = ("expr",)
326
+ expr: Expr
243
327
 
244
- class JSBoolean(JSExpr):
245
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
246
- is_primary: ClassVar[bool] = True
247
- value: bool
328
+ @override
329
+ def emit(self, out: list[str]) -> None:
330
+ self.expr.emit(out)
248
331
 
249
- def __init__(self, value: bool):
250
- self.value = value
332
+ @override
333
+ def render(self) -> VDOMNode:
334
+ return self.expr.render()
251
335
 
252
336
  @override
253
- def emit(self) -> str:
254
- return "true" if self.value else "false"
337
+ def precedence(self) -> int:
338
+ return self.expr.precedence()
255
339
 
340
+ @override
341
+ def transpile_call(
342
+ self,
343
+ args: list[ast.expr],
344
+ kwargs: dict[str, ast.expr],
345
+ ctx: Transpiler,
346
+ ) -> Expr:
347
+ return self.expr.transpile_call(args, kwargs, ctx)
256
348
 
257
- class JSNull(JSExpr):
258
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
259
- is_primary: ClassVar[bool] = True
349
+ @override
350
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
351
+ return self.expr.transpile_getattr(attr, ctx)
260
352
 
261
353
  @override
262
- def emit(self) -> str:
263
- return "null"
354
+ def transpile_subscript(self, key: ast.expr, ctx: Transpiler) -> Expr:
355
+ return self.expr.transpile_subscript(key, ctx)
264
356
 
357
+ @override
358
+ def __call__(self, *args: object, **kwargs: object) -> Expr: # pyright: ignore[reportIncompatibleMethodOverride]
359
+ return self.expr(*args, **kwargs)
265
360
 
266
- class JSUndefined(JSExpr):
267
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
268
- is_primary: ClassVar[bool] = True
361
+ @override
362
+ def __getitem__(self, key: object) -> Expr: # pyright: ignore[reportIncompatibleMethodOverride]
363
+ return self.expr[key]
269
364
 
270
365
  @override
271
- def emit(self) -> str:
272
- return "undefined"
366
+ def __getattr__(self, attr: str) -> Expr: # pyright: ignore[reportIncompatibleMethodOverride]
367
+ return getattr(self.expr, attr)
273
368
 
274
369
 
275
- class JSArray(JSExpr):
276
- __slots__ = ("elements",) # pyright: ignore[reportUnannotatedClassAttribute]
277
- is_primary: ClassVar[bool] = True
278
- elements: Sequence[JSExpr]
370
+ @dataclass(slots=True, init=False)
371
+ class Jsx(ExprWrapper):
372
+ """JSX wrapper that makes any Expr callable as a component.
279
373
 
280
- def __init__(self, elements: Sequence[JSExpr]):
281
- self.elements = elements
374
+ When called in transpiled code, produces Element(tag=expr, ...).
375
+ This enables patterns like `Jsx(Member(AppShell, "Header"))` to emit
376
+ `<AppShell.Header ... />`.
282
377
 
283
- @override
284
- def emit(self) -> str:
285
- inner = ", ".join(e.emit() for e in self.elements)
286
- return f"[{inner}]"
378
+ Example:
379
+ app_shell = Import("AppShell", "@mantine/core")
380
+ Header = Jsx(Member(app_shell, "Header"))
381
+ # In @javascript:
382
+ # Header(height=60) -> <AppShell_1.Header height={60} />
383
+ """
287
384
 
385
+ expr: Expr
386
+ id: str
288
387
 
289
- class JSSpread(JSExpr):
290
- __slots__ = ("expr",) # pyright: ignore[reportUnannotatedClassAttribute]
291
- expr: JSExpr
388
+ def __init__(self, expr: Expr) -> None:
389
+ from pulse.transpiler.id import next_id
292
390
 
293
- def __init__(self, expr: JSExpr):
294
391
  self.expr = expr
392
+ self.id = next_id()
295
393
 
296
394
  @override
297
- def emit(self) -> str:
298
- return f"...{self.expr.emit()}"
395
+ def transpile_call(
396
+ self,
397
+ args: list[ast.expr],
398
+ kwargs: dict[str, ast.expr],
399
+ ctx: "Transpiler",
400
+ ) -> Expr:
401
+ """Transpile a call to this JSX wrapper into an Element.
402
+
403
+ Positional args become children, keyword args become props.
404
+ The `key` kwarg is extracted specially.
405
+ """
406
+ children: list[Node] = [ctx.emit_expr(a) for a in args]
407
+
408
+ props: dict[str, Prop] = {}
409
+ key: str | Expr | None = None
410
+ for k, v in kwargs.items():
411
+ v = ctx.emit_expr(v)
412
+ if k == "key":
413
+ # Accept any expression as key for transpilation
414
+ if isinstance(v, Literal) and isinstance(v.value, str):
415
+ key = v.value # Optimize string literals
416
+ else:
417
+ key = v # Keep as expression
418
+ else:
419
+ props[k] = v
299
420
 
421
+ return Element(
422
+ tag=self.expr,
423
+ props=props if props else None,
424
+ children=children if children else None,
425
+ key=key,
426
+ )
300
427
 
301
- class JSProp(JSExpr):
302
- __slots__ = ("key", "value") # pyright: ignore[reportUnannotatedClassAttribute]
303
- key: JSString
304
- value: JSExpr
428
+ @override
429
+ def __call__(self, *args: Any, **kwargs: Any) -> Element:
430
+ """Allow calling Jsx in Python code.
305
431
 
306
- def __init__(self, key: JSString, value: JSExpr):
307
- self.key = key
308
- self.value = value
432
+ Supports two usage patterns:
433
+ 1. Decorator: @Jsx(expr) def Component(...): ...
434
+ 2. Call: Jsx(expr)(props, children) -> Element
435
+ """
309
436
 
310
- @override
311
- def emit(self) -> str:
312
- return f"{self.key.emit()}: {self.value.emit()}"
437
+ # Normal call: build Element
438
+ props: dict[str, object] = {}
439
+ key: str | None = None
440
+ children: list[Node] = list(args)
313
441
 
442
+ for k, v in kwargs.items():
443
+ if k == "key":
444
+ if not isinstance(v, str):
445
+ raise ValueError("key must be a string")
446
+ key = v
447
+ else:
448
+ props[k] = v
314
449
 
315
- class JSComputedProp(JSExpr):
316
- __slots__ = ("key", "value") # pyright: ignore[reportUnannotatedClassAttribute]
317
- key: JSExpr
318
- value: JSExpr
450
+ return Element(
451
+ tag=self.expr,
452
+ props=props if props else None,
453
+ children=children if children else None,
454
+ key=key,
455
+ )
319
456
 
320
- def __init__(self, key: JSExpr, value: JSExpr):
321
- self.key = key
322
- self.value = value
457
+
458
+ @dataclass(slots=True)
459
+ class Signature(ExprWrapper):
460
+ """Wraps an Expr with signature information for type checking.
461
+
462
+ When you call expr.as_(callable_type), this creates a Signature wrapper
463
+ that stores the callable's signature for introspection, while delegating
464
+ all other behavior to the wrapped expression.
465
+
466
+ Example:
467
+ button = Import("Button", "@mantine/core")
468
+ typed_button = Signature(button, signature_of_callable)
469
+ """
470
+
471
+ expr: Expr
472
+ sig: Any # inspect.Signature, but use Any for type compatibility
473
+
474
+
475
+ @dataclass(slots=True)
476
+ class Value(Expr):
477
+ """Wraps a non-primitive Python value for pass-through serialization.
478
+
479
+ Use cases:
480
+ - Complex prop values: options={"a": 1, "b": 2}
481
+ - Server-computed data passed to client components
482
+ - Any value that doesn't need expression semantics
483
+ """
484
+
485
+ value: Any
323
486
 
324
487
  @override
325
- def emit(self) -> str:
326
- return f"[{self.key.emit()}]: {self.value.emit()}"
488
+ def emit(self, out: list[str]) -> None:
489
+ _emit_value(self.value, out)
490
+
491
+ @override
492
+ def render(self) -> VDOMNode:
493
+ raise TypeError("Value cannot be rendered as VDOMExpr; use coerce_json instead")
494
+
495
+
496
+ class Element(Expr):
497
+ """A React element: built-in tag, fragment, or client component.
327
498
 
499
+ Tag conventions:
500
+ - "" (empty string): Fragment
501
+ - "div", "span", etc.: HTML element
502
+ - "$$ComponentId": Client component (registered in JS registry)
503
+ - Expr (Import, Member, etc.): Direct component reference for transpilation
504
+ """
505
+
506
+ __slots__: tuple[str, ...] = ("tag", "props", "children", "key")
328
507
 
329
- class JSObjectExpr(JSExpr):
330
- __slots__ = ("props",) # pyright: ignore[reportUnannotatedClassAttribute]
331
- is_primary: ClassVar[bool] = True
332
- props: Sequence[JSProp | JSComputedProp | JSSpread]
508
+ tag: str | Expr
509
+ props: dict[str, Any] | None
510
+ children: Sequence[Node] | None
511
+ key: str | Expr | None
333
512
 
334
- def __init__(self, props: Sequence[JSProp | JSComputedProp | JSSpread]):
513
+ def __init__(
514
+ self,
515
+ tag: str | Expr,
516
+ props: dict[str, Any] | None = None,
517
+ children: Sequence[Node] | None = None,
518
+ key: str | Expr | None = None,
519
+ ) -> None:
520
+ self.tag = tag
335
521
  self.props = props
522
+ if children is None:
523
+ self.children = None
524
+ else:
525
+ if isinstance(tag, str):
526
+ parent_name = tag[2:] if tag.startswith("$$") else tag
527
+ else:
528
+ tag_out: list[str] = []
529
+ tag.emit(tag_out)
530
+ parent_name = "".join(tag_out)
531
+ self.children = flatten_children(
532
+ children,
533
+ parent_name=parent_name,
534
+ warn_stacklevel=5,
535
+ )
536
+ self.key = key
537
+ if self.key is None and self.props:
538
+ self.key = self.props.pop("key", None)
539
+
540
+ def _emit_key(self, out: list[str]) -> None:
541
+ """Emit key prop (string or expression)."""
542
+ if self.key is None:
543
+ return
544
+ if isinstance(self.key, str):
545
+ out.append('key="')
546
+ out.append(_escape_jsx_attr(self.key))
547
+ out.append('"')
548
+ else:
549
+ # Expression key: key={expr}
550
+ out.append("key={")
551
+ self.key.emit(out)
552
+ out.append("}")
336
553
 
337
554
  @override
338
- def emit(self) -> str:
339
- inner = ", ".join(p.emit() for p in self.props)
340
- return "{" + inner + "}"
555
+ def emit(self, out: list[str]) -> None:
556
+ # Fragment (only for string tags)
557
+ if self.tag == "":
558
+ if self.key is not None:
559
+ # Fragment with key needs explicit Fragment component
560
+ out.append("<Fragment ")
561
+ self._emit_key(out)
562
+ out.append(">")
563
+ for c in self.children or []:
564
+ _emit_jsx_child(c, out)
565
+ out.append("</Fragment>")
566
+ else:
567
+ out.append("<>")
568
+ for c in self.children or []:
569
+ _emit_jsx_child(c, out)
570
+ out.append("</>")
571
+ return
572
+
573
+ # Resolve tag - either emit Expr or use string (strip $$ prefix)
574
+ tag_out: list[str] = []
575
+ if isinstance(self.tag, Expr):
576
+ self.tag.emit(tag_out)
577
+ else:
578
+ tag_out.append(self.tag[2:] if self.tag.startswith("$$") else self.tag)
579
+ tag_str = "".join(tag_out)
580
+
581
+ # Build props into a separate buffer to check if empty
582
+ props_out: list[str] = []
583
+ if self.key is not None:
584
+ self._emit_key(props_out)
585
+ if self.props:
586
+ for name, value in self.props.items():
587
+ if props_out:
588
+ props_out.append(" ")
589
+ _emit_jsx_prop(name, value, props_out)
590
+
591
+ # Build children into a separate buffer to check if empty
592
+ children_out: list[str] = []
593
+ for c in self.children or []:
594
+ _emit_jsx_child(c, children_out)
595
+
596
+ # Self-closing if no children
597
+ if not children_out:
598
+ out.append("<")
599
+ out.append(tag_str)
600
+ if props_out:
601
+ out.append(" ")
602
+ out.extend(props_out)
603
+ out.append(" />")
604
+ return
341
605
 
606
+ # Open tag
607
+ out.append("<")
608
+ out.append(tag_str)
609
+ if props_out:
610
+ out.append(" ")
611
+ out.extend(props_out)
612
+ out.append(">")
613
+ # Children
614
+ out.extend(children_out)
615
+ # Close tag
616
+ out.append("</")
617
+ out.append(tag_str)
618
+ out.append(">")
342
619
 
343
- class JSUnary(JSExpr):
344
- __slots__ = ("op", "operand") # pyright: ignore[reportUnannotatedClassAttribute]
345
- op: str # '-', '+', '!', 'typeof', 'await'
346
- operand: JSExpr
620
+ @override
621
+ def transpile_subscript(self, key: ast.expr, ctx: Transpiler) -> Expr:
622
+ """Transpile subscript as adding children to this element.
347
623
 
348
- def __init__(self, op: str, operand: JSExpr):
349
- self.op = op
350
- self.operand = operand
624
+ Handles both single children and tuple of children.
625
+ """
626
+ if self.children:
627
+ raise TranspileError(
628
+ f"Element '{self.tag}' already has children; cannot add more via subscript"
629
+ )
351
630
 
352
- @override
353
- def emit(self) -> str:
354
- operand_code = _emit_child_for_binary_like(
355
- self.operand, parent_op=self.op, side="unary"
631
+ # Convert key to list of children
632
+ if isinstance(key, ast.Tuple):
633
+ children = [ctx.emit_expr(e) for e in key.elts]
634
+ else:
635
+ children = [ctx.emit_expr(key)]
636
+
637
+ return Element(
638
+ tag=self.tag,
639
+ props=self.props,
640
+ children=children,
641
+ key=self.key,
356
642
  )
357
- if self.op == "typeof":
358
- return f"typeof {operand_code}"
359
- return f"{self.op}{operand_code}"
360
643
 
644
+ @override
645
+ def __getitem__(self, key: Any) -> Element: # pyright: ignore[reportIncompatibleMethodOverride]
646
+ """Return new Element with children set via subscript.
647
+
648
+ Raises if this element already has children.
649
+ Accepts a single child or a Sequence of children.
650
+ """
651
+ if self.children:
652
+ raise ValueError(
653
+ f"Element '{self.tag}' already has children; cannot add more via subscript"
654
+ )
655
+
656
+ # Convert key to sequence of children
657
+ if isinstance(key, (list, tuple)):
658
+ children = list(cast(list[Any] | tuple[Any, ...], key))
659
+ else:
660
+ children = [key]
661
+
662
+ return Element(
663
+ tag=self.tag,
664
+ props=self.props,
665
+ children=children,
666
+ key=self.key,
667
+ )
361
668
 
362
- class JSAwait(JSExpr):
363
- __slots__ = ("operand",) # pyright: ignore[reportUnannotatedClassAttribute]
364
- operand: JSExpr
669
+ def with_children(self, children: Sequence[Node]) -> Element:
670
+ """Return new Element with children set.
365
671
 
366
- def __init__(self, operand: JSExpr):
367
- self.operand = operand
672
+ Raises if this element already has children.
673
+ """
674
+ if self.children:
675
+ raise ValueError(
676
+ f"Element '{self.tag}' already has children; cannot add more via subscript"
677
+ )
678
+ return Element(
679
+ tag=self.tag,
680
+ props=self.props,
681
+ children=list(children),
682
+ key=self.key,
683
+ )
368
684
 
369
685
  @override
370
- def emit(self) -> str:
371
- operand_code = _emit_child_for_binary_like(
372
- self.operand, parent_op="await", side="unary"
686
+ def render(self) -> VDOMNode:
687
+ """Element rendering is handled by Renderer.render_node(), not render().
688
+
689
+ This method validates render-time constraints and raises TypeError
690
+ because Element produces VDOMElement, not VDOMExpr.
691
+ """
692
+ # Validate key is string or numeric (not arbitrary Expr) during rendering
693
+ if self.key is not None and not isinstance(self.key, (str, int)):
694
+ raise TypeError(
695
+ f"Element key must be a string or int for rendering, got {type(self.key).__name__}. "
696
+ + "Expression keys are only valid during transpilation (emit)."
697
+ )
698
+ raise TypeError(
699
+ "Element cannot be rendered as VDOMExpr; use Renderer.render_node() instead"
373
700
  )
374
- return f"await {operand_code}"
375
701
 
376
702
 
377
- class JSBinary(JSExpr):
378
- __slots__ = ("left", "op", "right") # pyright: ignore[reportUnannotatedClassAttribute]
379
- left: JSExpr
380
- op: str
381
- right: JSExpr
703
+ @dataclass(slots=True)
704
+ class PulseNode:
705
+ """A Pulse server-side component instance.
382
706
 
383
- def __init__(self, left: JSExpr, op: str, right: JSExpr):
384
- self.left = left
385
- self.op = op
386
- self.right = right
707
+ During rendering, PulseNode is called and replaced by its returned tree.
708
+ Can only appear in VDOM context (render path), never in transpiled code.
709
+ """
387
710
 
388
- @override
389
- def emit(self) -> str:
390
- # Left child
391
- force_left_paren = False
392
- # Special JS grammar rule: left operand of ** cannot be a unary +/- without parentheses
393
- if (
394
- self.op == "**"
395
- and isinstance(self.left, JSUnary)
396
- and self.left.op in {"-", "+"}
397
- ):
398
- force_left_paren = True
399
- left_code = _emit_child_for_binary_like(
400
- self.left,
401
- parent_op=self.op,
402
- side="left",
403
- force_paren=force_left_paren,
711
+ fn: Any # Callable[..., Node]
712
+ args: tuple[Any, ...] = ()
713
+ kwargs: dict[str, Any] = field(default_factory=dict)
714
+ key: str | None = None
715
+ name: str | None = None # Optional component name for debug messages.
716
+ # Renderer state (mutable, set during render)
717
+ hooks: Any = None # HookContext
718
+ contents: Node | None = None
719
+
720
+ def emit(self, out: list[str]) -> None:
721
+ fn_name = getattr(self.fn, "__name__", "unknown")
722
+ raise TypeError(
723
+ f"Cannot transpile PulseNode '{fn_name}'. "
724
+ + "Server components must be rendered, not transpiled."
725
+ )
726
+
727
+ def __getitem__(self, children_arg: "Node | tuple[Node, ...]"):
728
+ if self.args:
729
+ raise ValueError(
730
+ "PulseNode already received positional args; pass children in the call or via brackets, not both."
731
+ )
732
+ if not isinstance(children_arg, tuple):
733
+ children_arg = (children_arg,)
734
+ parent_name = self.name
735
+ if parent_name is None:
736
+ parent_name = getattr(self.fn, "__name__", "Component")
737
+ flat = flatten_children(
738
+ children_arg,
739
+ parent_name=parent_name,
740
+ warn_stacklevel=5,
404
741
  )
405
- # Right child
406
- right_code = _emit_child_for_binary_like(
407
- self.right, parent_op=self.op, side="right"
742
+ return PulseNode(
743
+ fn=self.fn,
744
+ args=tuple(flat),
745
+ kwargs=self.kwargs,
746
+ key=self.key,
747
+ name=self.name,
408
748
  )
409
- return f"{left_code} {self.op} {right_code}"
410
749
 
411
750
 
412
- class JSLogicalChain(JSExpr):
413
- __slots__ = ("op", "values") # pyright: ignore[reportUnannotatedClassAttribute]
414
- op: str # '&&' or '||'
415
- values: Sequence[JSExpr]
751
+ # =============================================================================
752
+ # Children normalization helpers
753
+ # =============================================================================
754
+ def flatten_children(
755
+ children: Sequence[Node | Iterable[Node]],
756
+ *,
757
+ parent_name: str,
758
+ warn_stacklevel: int = 5,
759
+ ) -> list[Node]:
760
+ if env.pulse_env == "dev":
761
+ return _flatten_children_dev(
762
+ children, parent_name=parent_name, warn_stacklevel=warn_stacklevel
763
+ )
764
+ return _flatten_children_prod(children)
765
+
766
+
767
+ def _flatten_children_prod(children: Sequence[Node | Iterable[Node]]) -> list[Node]:
768
+ flat: list[Node] = []
769
+
770
+ def visit(item: Node | Iterable[Node]) -> None:
771
+ if isinstance(item, dict):
772
+ raise TypeError("Dict is not a valid child")
773
+ if isinstance(item, Iterable) and not isinstance(item, str):
774
+ for sub in item:
775
+ visit(sub)
776
+ else:
777
+ flat.append(item)
778
+
779
+ for child in children:
780
+ visit(child)
781
+
782
+ return flat
783
+
784
+
785
+ def _flatten_children_dev(
786
+ children: Sequence[Node | Iterable[Node]],
787
+ *,
788
+ parent_name: str,
789
+ warn_stacklevel: int = 5,
790
+ ) -> list[Node]:
791
+ flat: list[Node] = []
792
+ seen_keys: set[str] = set()
793
+
794
+ def visit(item: Node | Iterable[Node]) -> None:
795
+ if isinstance(item, dict):
796
+ raise TypeError("Dict is not a valid child")
797
+ if isinstance(item, Iterable) and not isinstance(item, str):
798
+ missing_key = False
799
+ for sub in item:
800
+ if isinstance(sub, PulseNode) and sub.key is None:
801
+ missing_key = True
802
+ if isinstance(sub, Element) and _normalize_key(sub.key) is None:
803
+ missing_key = True
804
+ visit(sub) # type: ignore[arg-type]
805
+ if missing_key:
806
+ clean_name = clean_element_name(parent_name)
807
+ warnings.warn(
808
+ (
809
+ f"[Pulse] Iterable children of {clean_name} contain elements without 'key'. "
810
+ "Add a stable 'key' to each element inside iterables to improve reconciliation."
811
+ ),
812
+ stacklevel=warn_stacklevel,
813
+ )
814
+ else:
815
+ if isinstance(item, PulseNode) and item.key is not None:
816
+ if item.key in seen_keys:
817
+ clean_name = clean_element_name(parent_name)
818
+ raise ValueError(
819
+ f"[Pulse] Duplicate key '{item.key}' found among children of {clean_name}. "
820
+ + "Keys must be unique per sibling set."
821
+ )
822
+ seen_keys.add(item.key)
823
+ if isinstance(item, Element):
824
+ key = _normalize_key(item.key)
825
+ if key is not None:
826
+ if key in seen_keys:
827
+ clean_name = clean_element_name(parent_name)
828
+ raise ValueError(
829
+ f"[Pulse] Duplicate key '{key}' found among children of {clean_name}. "
830
+ + "Keys must be unique per sibling set."
831
+ )
832
+ seen_keys.add(key)
833
+ flat.append(item)
834
+
835
+ for child in children:
836
+ visit(child)
837
+
838
+ return flat
839
+
840
+
841
+ def clean_element_name(parent_name: str) -> str:
842
+ if parent_name.startswith("<") and parent_name.endswith(">"):
843
+ return parent_name
844
+ return f"<{parent_name}>"
845
+
846
+
847
+ def _normalize_key(key: object | None) -> str | None:
848
+ if isinstance(key, Literal):
849
+ return key.value if isinstance(key.value, str) else None
850
+ return key if isinstance(key, str) else None
851
+
852
+
853
+ # =============================================================================
854
+ # Expression Nodes
855
+ # =============================================================================
856
+
857
+
858
+ @dataclass(slots=True)
859
+ class Identifier(Expr):
860
+ """JS identifier: x, foo, myFunc"""
416
861
 
417
- def __init__(self, op: str, values: Sequence[JSExpr]):
418
- self.op = op
419
- self.values = values
862
+ name: str
420
863
 
421
864
  @override
422
- def emit(self) -> str:
423
- if len(self.values) == 1:
424
- return self.values[0].emit()
425
- parts: list[str] = []
426
- for v in self.values:
427
- # No strict left/right in chains, but treat as middle
428
- code = _emit_child_for_binary_like(v, parent_op=self.op, side="chain")
429
- parts.append(code)
430
- return f" {self.op} ".join(parts)
865
+ def emit(self, out: list[str]) -> None:
866
+ out.append(self.name)
431
867
 
868
+ @override
869
+ def render(self) -> VDOMNode:
870
+ return {"t": "id", "name": self.name}
432
871
 
433
- class JSTertiary(JSExpr):
434
- __slots__ = ("test", "if_true", "if_false") # pyright: ignore[reportUnannotatedClassAttribute]
435
- test: JSExpr
436
- if_true: JSExpr
437
- if_false: JSExpr
438
872
 
439
- def __init__(self, test: JSExpr, if_true: JSExpr, if_false: JSExpr):
440
- self.test = test
441
- self.if_true = if_true
442
- self.if_false = if_false
873
+ @dataclass(slots=True)
874
+ class Literal(Expr):
875
+ """JS literal: 42, "hello", true, null"""
876
+
877
+ value: int | float | str | bool | None
443
878
 
444
879
  @override
445
- def emit(self) -> str:
446
- return f"{self.test.emit()} ? {self.if_true.emit()} : {self.if_false.emit()}"
880
+ def emit(self, out: list[str]) -> None:
881
+ if self.value is None:
882
+ out.append("null")
883
+ elif isinstance(self.value, bool):
884
+ out.append("true" if self.value else "false")
885
+ elif isinstance(self.value, str):
886
+ out.append('"')
887
+ out.append(_escape_string(self.value))
888
+ out.append('"')
889
+ else:
890
+ out.append(str(self.value))
447
891
 
892
+ @override
893
+ def render(self) -> VDOMNode:
894
+ return self.value
448
895
 
449
- class JSFunctionDef(JSExpr):
450
- __slots__ = ("params", "body", "name", "is_async") # pyright: ignore[reportUnannotatedClassAttribute]
451
- params: Sequence[str]
452
- body: Sequence[JSStmt]
453
- name: str | None
454
- is_async: bool
455
896
 
456
- def __init__(
457
- self,
458
- params: Sequence[str],
459
- body: Sequence[JSStmt],
460
- name: str | None = None,
461
- is_async: bool = False,
462
- ):
463
- self.params = params
464
- self.body = body
465
- self.name = name
466
- self.is_async = is_async
467
-
468
- @override
469
- def emit(self) -> str:
470
- params = ", ".join(self.params)
471
- body_code = "\n".join(s.emit() for s in self.body)
472
- prefix = "async " if self.is_async else ""
473
- if self.name:
474
- return f"{prefix}function {self.name}({params}){{\n{body_code}\n}}"
475
- return f"{prefix}function({params}){{\n{body_code}\n}}"
897
+ class Undefined(Expr):
898
+ """JS undefined literal.
476
899
 
900
+ Use Undefined() for JS `undefined`. Literal(None) emits `null`.
901
+ This is a singleton-like class with no fields.
902
+ """
477
903
 
478
- class JSTemplate(JSExpr):
479
- __slots__ = ("parts",) # pyright: ignore[reportUnannotatedClassAttribute]
480
- is_primary: ClassVar[bool] = True
481
- parts: Sequence[str | JSExpr]
904
+ __slots__: tuple[str, ...] = ()
482
905
 
483
- def __init__(self, parts: Sequence[str | JSExpr]):
484
- # parts are either raw strings (literal text) or JSExpr instances which are
485
- # emitted inside ${...}
486
- self.parts = parts
906
+ @override
907
+ def emit(self, out: list[str]) -> None:
908
+ out.append("undefined")
487
909
 
488
910
  @override
489
- def emit(self) -> str:
490
- out: list[str] = ["`"]
491
- for p in self.parts:
492
- if isinstance(p, str):
493
- out.append(
494
- p.replace("\\", "\\\\")
495
- .replace("`", "\\`")
496
- .replace("${", "\\${")
497
- .replace("\n", "\\n")
498
- .replace("\r", "\\r")
499
- .replace("\t", "\\t")
500
- .replace("\b", "\\b")
501
- .replace("\f", "\\f")
502
- .replace("\v", "\\v")
503
- .replace("\x00", "\\x00")
504
- .replace("\u2028", "\\u2028")
505
- .replace("\u2029", "\\u2029")
506
- )
507
- else:
508
- out.append("${" + p.emit() + "}")
509
- out.append("`")
510
- return "".join(out)
911
+ def render(self) -> VDOMNode:
912
+ return {"t": "undef"}
511
913
 
512
914
 
513
- class JSMember(JSExpr):
514
- __slots__ = ("obj", "prop") # pyright: ignore[reportUnannotatedClassAttribute]
515
- obj: JSExpr
516
- prop: str
915
+ # Singleton instance for convenience
916
+ UNDEFINED = Undefined()
517
917
 
518
- def __init__(self, obj: JSExpr, prop: str):
519
- self.obj = obj
520
- self.prop = prop
521
918
 
522
- @override
523
- def emit(self) -> str:
524
- obj_code = _emit_child_for_primary(self.obj)
525
- return f"{obj_code}.{self.prop}"
919
+ @dataclass(slots=True)
920
+ class Array(Expr):
921
+ """JS array: [a, b, c]"""
922
+
923
+ elements: Sequence[Expr]
526
924
 
527
925
  @override
528
- def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
529
- """Called when this member is used as a function: obj.prop(args).
926
+ def emit(self, out: list[str]) -> None:
927
+ out.append("[")
928
+ for i, e in enumerate(self.elements):
929
+ if i > 0:
930
+ out.append(", ")
931
+ e.emit(out)
932
+ out.append("]")
530
933
 
531
- Checks for Python builtin method transpilation (e.g., str.upper -> toUpperCase),
532
- then falls back to regular JSMemberCall.
533
- """
534
- if kwargs:
535
- raise JSCompilationError("Keyword arguments not supported in method call")
536
- # Convert args to JSExpr
537
- js_args = [JSExpr.of(a) for a in args]
538
- # Check for Python builtin method transpilation (late import to avoid cycle)
539
- from pulse.transpiler.builtins import emit_method
934
+ @override
935
+ def render(self) -> VDOMNode:
936
+ return {"t": "array", "items": [e.render() for e in self.elements]}
540
937
 
541
- result = emit_method(self.obj, self.prop, js_args)
542
- if result is not None:
543
- return result
544
- return JSMemberCall(self.obj, self.prop, js_args)
545
938
 
939
+ @dataclass(slots=True)
940
+ class Object(Expr):
941
+ """JS object: { key: value }"""
546
942
 
547
- class JSSubscript(JSExpr):
548
- __slots__ = ("obj", "index") # pyright: ignore[reportUnannotatedClassAttribute]
549
- obj: JSExpr
550
- index: JSExpr
943
+ props: Sequence[tuple[str, Expr]]
551
944
 
552
- def __init__(self, obj: JSExpr, index: JSExpr):
553
- self.obj = obj
554
- self.index = index
945
+ @override
946
+ def emit(self, out: list[str]) -> None:
947
+ out.append("{")
948
+ for i, (k, v) in enumerate(self.props):
949
+ if i > 0:
950
+ out.append(", ")
951
+ out.append('"')
952
+ out.append(_escape_string(k))
953
+ out.append('": ')
954
+ v.emit(out)
955
+ out.append("}")
555
956
 
556
957
  @override
557
- def emit(self) -> str:
558
- obj_code = _emit_child_for_primary(self.obj)
559
- return f"{obj_code}[{self.index.emit()}]"
958
+ def render(self) -> VDOMNode:
959
+ return {"t": "object", "props": {k: v.render() for k, v in self.props}}
960
+
560
961
 
962
+ @dataclass(slots=True)
963
+ class Member(Expr):
964
+ """JS member access: obj.prop"""
561
965
 
562
- class JSCall(JSExpr):
563
- __slots__ = ("callee", "args") # pyright: ignore[reportUnannotatedClassAttribute]
564
- callee: JSExpr # typically JSIdentifier
565
- args: Sequence[JSExpr]
966
+ obj: Expr
967
+ prop: str
566
968
 
567
- def __init__(self, callee: JSExpr, args: Sequence[JSExpr]):
568
- self.callee = callee
569
- self.args = args
969
+ @override
970
+ def emit(self, out: list[str]) -> None:
971
+ _emit_primary(self.obj, out)
972
+ out.append(".")
973
+ out.append(self.prop)
570
974
 
571
975
  @override
572
- def emit(self) -> str:
573
- fn = _emit_child_for_primary(self.callee)
574
- return f"{fn}({', '.join(a.emit() for a in self.args)})"
976
+ def render(self) -> VDOMNode:
977
+ return {"t": "member", "obj": self.obj.render(), "prop": self.prop}
575
978
 
576
979
 
577
- class JSMemberCall(JSExpr):
578
- __slots__ = ("obj", "method", "args") # pyright: ignore[reportUnannotatedClassAttribute]
579
- obj: JSExpr
580
- method: str
581
- args: Sequence[JSExpr]
980
+ @dataclass(slots=True)
981
+ class Subscript(Expr):
982
+ """JS subscript access: obj[key]"""
582
983
 
583
- def __init__(self, obj: JSExpr, method: str, args: Sequence[JSExpr]):
584
- self.obj = obj
585
- self.method = method
586
- self.args = args
984
+ obj: Expr
985
+ key: Expr
587
986
 
588
987
  @override
589
- def emit(self) -> str:
590
- obj_code = _emit_child_for_primary(self.obj)
591
- return f"{obj_code}.{self.method}({', '.join(a.emit() for a in self.args)})"
988
+ def emit(self, out: list[str]) -> None:
989
+ _emit_primary(self.obj, out)
990
+ out.append("[")
991
+ self.key.emit(out)
992
+ out.append("]")
592
993
 
994
+ @override
995
+ def render(self) -> VDOMNode:
996
+ return {"t": "sub", "obj": self.obj.render(), "key": self.key.render()}
593
997
 
594
- class JSNew(JSExpr):
595
- __slots__ = ("ctor", "args") # pyright: ignore[reportUnannotatedClassAttribute]
596
- ctor: JSExpr
597
- args: Sequence[JSExpr]
598
998
 
599
- def __init__(self, ctor: JSExpr, args: Sequence[JSExpr]):
600
- self.ctor = ctor
601
- self.args = args
999
+ @dataclass(slots=True)
1000
+ class Call(Expr):
1001
+ """JS function call: fn(args)"""
1002
+
1003
+ callee: Expr
1004
+ args: Sequence[Expr]
602
1005
 
603
1006
  @override
604
- def emit(self) -> str:
605
- ctor_code = _emit_child_for_primary(self.ctor)
606
- return f"new {ctor_code}({', '.join(a.emit() for a in self.args)})"
1007
+ def emit(self, out: list[str]) -> None:
1008
+ _emit_primary(self.callee, out)
1009
+ out.append("(")
1010
+ for i, a in enumerate(self.args):
1011
+ if i > 0:
1012
+ out.append(", ")
1013
+ a.emit(out)
1014
+ out.append(")")
607
1015
 
1016
+ @override
1017
+ def render(self) -> VDOMNode:
1018
+ return {
1019
+ "t": "call",
1020
+ "callee": self.callee.render(),
1021
+ "args": [a.render() for a in self.args],
1022
+ }
1023
+
1024
+
1025
+ @dataclass(slots=True)
1026
+ class Unary(Expr):
1027
+ """JS unary expression: -x, !x, typeof x"""
1028
+
1029
+ op: str
1030
+ operand: Expr
608
1031
 
609
- class JSArrowFunction(JSExpr):
610
- __slots__ = ("params_code", "body") # pyright: ignore[reportUnannotatedClassAttribute]
611
- params_code: str # already formatted e.g. 'x' or '(a, b)' or '([k, v])'
612
- body: JSExpr | JSBlock
1032
+ @override
1033
+ def precedence(self) -> int:
1034
+ op = self.op
1035
+ tag = "+u" if op == "+" else ("-u" if op == "-" else op)
1036
+ return _PRECEDENCE.get(tag, 17)
613
1037
 
614
- def __init__(self, params_code: str, body: JSExpr | JSBlock):
615
- self.params_code = params_code
616
- self.body = body
1038
+ @override
1039
+ def emit(self, out: list[str]) -> None:
1040
+ if self.op in {"typeof", "await", "void", "delete"}:
1041
+ out.append(self.op)
1042
+ out.append(" ")
1043
+ else:
1044
+ out.append(self.op)
1045
+ _emit_paren(self.operand, self.op, "unary", out)
617
1046
 
618
1047
  @override
619
- def emit(self) -> str:
620
- return f"{self.params_code} => {self.body.emit()}"
1048
+ def render(self) -> VDOMNode:
1049
+ if self.op == "await":
1050
+ raise TypeError("await is not supported in VDOM expressions")
1051
+ return {"t": "unary", "op": self.op, "arg": self.operand.render()}
621
1052
 
622
1053
 
623
- class JSComma(JSExpr):
624
- __slots__ = ("values",) # pyright: ignore[reportUnannotatedClassAttribute]
625
- values: Sequence[JSExpr]
1054
+ @dataclass(slots=True)
1055
+ class Binary(Expr):
1056
+ """JS binary expression: x + y, a && b"""
626
1057
 
627
- def __init__(self, values: Sequence[JSExpr]):
628
- self.values = values
1058
+ left: Expr
1059
+ op: str
1060
+ right: Expr
629
1061
 
630
1062
  @override
631
- def emit(self) -> str:
632
- # Always wrap comma expressions in parentheses to avoid precedence surprises
633
- inner = ", ".join(v.emit() for v in self.values)
634
- return f"({inner})"
1063
+ def precedence(self) -> int:
1064
+ return _PRECEDENCE.get(self.op, 0)
635
1065
 
1066
+ @override
1067
+ def emit(self, out: list[str]) -> None:
1068
+ # Special: ** with unary +/- on left needs parens
1069
+ force_left = (
1070
+ self.op == "**"
1071
+ and isinstance(self.left, Unary)
1072
+ and self.left.op in {"-", "+"}
1073
+ )
1074
+ if force_left:
1075
+ out.append("(")
1076
+ self.left.emit(out)
1077
+ out.append(")")
1078
+ else:
1079
+ _emit_paren(self.left, self.op, "left", out)
1080
+ out.append(" ")
1081
+ out.append(self.op)
1082
+ out.append(" ")
1083
+ _emit_paren(self.right, self.op, "right", out)
636
1084
 
637
- class JSTransformer(JSExpr):
638
- """JSExpr that wraps a function transforming JSExpr args to JSExpr output.
1085
+ @override
1086
+ def render(self) -> VDOMNode:
1087
+ return {
1088
+ "t": "binary",
1089
+ "op": self.op,
1090
+ "left": self.left.render(),
1091
+ "right": self.right.render(),
1092
+ }
639
1093
 
640
- Generalizes the pattern of call-only expressions. The wrapped function
641
- receives positional and keyword JSExpr arguments and returns a JSExpr.
642
1094
 
643
- Example:
644
- emit_len = JSTransformer(lambda x: JSMember(x, "length"), name="len")
645
- # When called: emit_len.emit_call([some_expr], {}) -> JSMember(some_expr, "length")
646
- """
1095
+ @dataclass(slots=True)
1096
+ class Ternary(Expr):
1097
+ """JS ternary expression: cond ? a : b"""
647
1098
 
648
- __slots__ = ("fn", "name") # pyright: ignore[reportUnannotatedClassAttribute]
649
- fn: Callable[..., JSExpr]
650
- name: str # Optional name for error messages
1099
+ cond: Expr
1100
+ then: Expr
1101
+ else_: Expr
651
1102
 
652
- def __init__(self, fn: Callable[..., JSExpr], name: str = ""):
653
- self.fn = fn
654
- self.name = name
1103
+ @override
1104
+ def precedence(self) -> int:
1105
+ return _PRECEDENCE["?:"]
655
1106
 
656
1107
  @override
657
- def emit(self) -> str:
658
- label = self.name or "JSTransformer"
659
- raise JSCompilationError(f"{label} cannot be emitted directly - must be called")
1108
+ def emit(self, out: list[str]) -> None:
1109
+ self.cond.emit(out)
1110
+ out.append(" ? ")
1111
+ self.then.emit(out)
1112
+ out.append(" : ")
1113
+ self.else_.emit(out)
660
1114
 
661
1115
  @override
662
- def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
663
- # Pass raw args to the transformer function - it decides what to convert
664
- if kwargs:
665
- return self.fn(*args, **kwargs)
666
- return self.fn(*args)
1116
+ def render(self) -> VDOMNode:
1117
+ return {
1118
+ "t": "ternary",
1119
+ "cond": self.cond.render(),
1120
+ "then": self.then.render(),
1121
+ "else_": self.else_.render(),
1122
+ }
1123
+
1124
+
1125
+ @dataclass(slots=True)
1126
+ class Arrow(Expr):
1127
+ """JS arrow function: (x) => expr or (x) => { ... }
1128
+
1129
+ body can be:
1130
+ - Expr: expression body, emits as `() => expr`
1131
+ - Sequence[Stmt]: statement body, emits as `() => { stmt1; stmt2; }`
1132
+ """
1133
+
1134
+ params: Sequence[str]
1135
+ body: Expr | Sequence[Stmt]
667
1136
 
668
1137
  @override
669
- def emit_subscript(self, indices: list[Any]) -> JSExpr:
670
- label = self.name or "JSTransformer"
671
- raise JSCompilationError(f"{label} cannot be subscripted")
1138
+ def precedence(self) -> int:
1139
+ # Arrow functions have very low precedence (assignment level)
1140
+ # This ensures they get wrapped in parens when used as callee in Call
1141
+ return 3
672
1142
 
673
1143
  @override
674
- def emit_getattr(self, attr: str) -> JSExpr:
675
- label = self.name or "JSTransformer"
676
- raise JSCompilationError(f"{label} cannot have attributes")
1144
+ def emit(self, out: list[str]) -> None:
1145
+ if len(self.params) == 1:
1146
+ out.append(self.params[0])
1147
+ else:
1148
+ out.append("(")
1149
+ out.append(", ".join(self.params))
1150
+ out.append(")")
1151
+ out.append(" => ")
1152
+ if isinstance(self.body, Expr):
1153
+ self.body.emit(out)
1154
+ else:
1155
+ out.append("{ ")
1156
+ for stmt in self.body:
1157
+ stmt.emit(out)
1158
+ out.append(" ")
1159
+ out.append("}")
677
1160
 
1161
+ @override
1162
+ def render(self) -> VDOMNode:
1163
+ if not isinstance(self.body, Expr):
1164
+ raise TypeError("Arrow with statement body cannot be rendered as VDOMExpr")
1165
+ return {"t": "arrow", "params": list(self.params), "body": self.body.render()}
678
1166
 
679
- _F = TypeVar("_F", bound=Callable[..., Any])
680
1167
 
1168
+ @dataclass(slots=True)
1169
+ class Template(Expr):
1170
+ """JS template literal: `hello ${name}`
681
1171
 
682
- @overload
683
- def js_transformer(arg: str) -> Callable[[_F], _F]: ...
1172
+ Parts alternate: [str, Expr, str, Expr, str, ...]
1173
+ Always starts and ends with a string (may be empty).
1174
+ """
684
1175
 
1176
+ parts: Sequence[str | Expr] # alternating, starting with str
685
1177
 
686
- @overload
687
- def js_transformer(arg: _F) -> _F: ...
1178
+ @override
1179
+ def emit(self, out: list[str]) -> None:
1180
+ out.append("`")
1181
+ for p in self.parts:
1182
+ if isinstance(p, str):
1183
+ out.append(_escape_template(p))
1184
+ else:
1185
+ out.append("${")
1186
+ p.emit(out)
1187
+ out.append("}")
1188
+ out.append("`")
688
1189
 
1190
+ @override
1191
+ def render(self) -> VDOMNode:
1192
+ rendered_parts: list[str | VDOMNode] = []
1193
+ for p in self.parts:
1194
+ if isinstance(p, str):
1195
+ rendered_parts.append(p)
1196
+ else:
1197
+ rendered_parts.append(p.render())
1198
+ return {"t": "template", "parts": rendered_parts}
689
1199
 
690
- def js_transformer(arg: str | _F) -> Callable[[_F], _F] | _F:
691
- """Decorator/helper for JSTransformer.
692
1200
 
693
- Usage:
694
- @js_transformer("len")
695
- def emit_len(x): ...
696
- or:
697
- emit_len = js_transformer(lambda x: ...)
1201
+ @dataclass(slots=True)
1202
+ class Spread(Expr):
1203
+ """JS spread: ...expr"""
698
1204
 
699
- Returns a JSTransformer, but the type signature lies and preserves
700
- the original function type. This allows decorated functions to have
701
- proper return types (e.g., NoReturn for throw).
702
- """
703
- if isinstance(arg, str):
1205
+ expr: Expr
704
1206
 
705
- def decorator(fn: _F) -> _F:
706
- return cast(_F, JSTransformer(fn, name=arg))
1207
+ @override
1208
+ def emit(self, out: list[str]) -> None:
1209
+ out.append("...")
1210
+ self.expr.emit(out)
707
1211
 
708
- return decorator
709
- elif callable(arg):
710
- return cast(_F, JSTransformer(arg))
711
- else:
712
- raise TypeError(
713
- "js_transformer expects a function or string (for decorator usage)"
714
- )
1212
+ @override
1213
+ def render(self) -> VDOMNode:
1214
+ raise TypeError("Spread cannot be rendered as VDOMExpr directly")
715
1215
 
716
1216
 
717
- class JSReturn(JSStmt):
718
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
719
- value: JSExpr
1217
+ @dataclass(slots=True)
1218
+ class New(Expr):
1219
+ """JS new expression: new Ctor(args)"""
720
1220
 
721
- def __init__(self, value: JSExpr):
722
- self.value = value
1221
+ ctor: Expr
1222
+ args: Sequence[Expr]
723
1223
 
724
1224
  @override
725
- def emit(self) -> str:
726
- return f"return {self.value.emit()};"
1225
+ def emit(self, out: list[str]) -> None:
1226
+ out.append("new ")
1227
+ self.ctor.emit(out)
1228
+ out.append("(")
1229
+ for i, a in enumerate(self.args):
1230
+ if i > 0:
1231
+ out.append(", ")
1232
+ a.emit(out)
1233
+ out.append(")")
727
1234
 
1235
+ @override
1236
+ def render(self) -> VDOMNode:
1237
+ return {
1238
+ "t": "new",
1239
+ "ctor": self.ctor.render(),
1240
+ "args": [a.render() for a in self.args],
1241
+ }
728
1242
 
729
- class JSThrow(JSStmt):
730
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
731
- value: JSExpr
732
1243
 
733
- def __init__(self, value: JSExpr):
734
- self.value = value
1244
+ class TransformerFn(Protocol):
1245
+ def __call__(self, *args: Any, ctx: Transpiler, **kwargs: Any) -> Expr: ...
735
1246
 
736
- @override
737
- def emit(self) -> str:
738
- return f"throw {self.value.emit()};"
739
1247
 
1248
+ _F = TypeVar("_F", bound=TransformerFn)
740
1249
 
741
- class JSStmtExpr(JSExpr):
742
- """Expression wrapper for a statement (e.g., throw).
743
1250
 
744
- Used for constructs like `throw(x)` that syntactically look like function calls
745
- but must be emitted as statements. When used as an expression-statement,
746
- the transpiler unwraps this and emits the inner statement directly.
747
- """
1251
+ @dataclass(slots=True)
1252
+ class Transformer(Expr, Generic[_F]):
1253
+ """Expr that wraps a function transforming args to Expr output.
748
1254
 
749
- __slots__ = ("stmt", "name") # pyright: ignore[reportUnannotatedClassAttribute]
750
- stmt: JSStmt
751
- name: str # For error messages (e.g., "throw")
1255
+ Used for Python->JS transpilation of functions, builtins, and module attrs.
1256
+ The wrapped function receives args/kwargs and ctx, and returns an Expr.
752
1257
 
753
- def __init__(self, stmt: JSStmt, name: str = ""):
754
- self.stmt = stmt
755
- self.name = name
1258
+ Example:
1259
+ emit_len = Transformer(lambda x, ctx: Member(ctx.emit_expr(x), "length"), name="len")
1260
+ # When called: emit_len.transpile_call([some_ast], {}, ctx) -> Member(some_expr, "length")
1261
+ """
1262
+
1263
+ fn: _F
1264
+ name: str = "" # For error messages
756
1265
 
757
1266
  @override
758
- def emit(self) -> str:
759
- label = self.name or "statement"
760
- raise JSCompilationError(
761
- f"'{label}' cannot be used inside an expression. "
762
- + "Use it as a standalone statement instead. "
763
- + f"For example, write `{label}(x)` on its own line, not `y = {label}(x)` or `f({label}(x))`."
764
- )
1267
+ def emit(self, out: list[str]) -> None:
1268
+ label = self.name or "Transformer"
1269
+ raise TypeError(f"{label} cannot be emitted directly - must be called")
765
1270
 
1271
+ @override
1272
+ def transpile_call(
1273
+ self,
1274
+ args: list[ast.expr],
1275
+ kwargs: dict[str, ast.expr],
1276
+ ctx: Transpiler,
1277
+ ) -> Expr:
1278
+ if kwargs:
1279
+ return self.fn(*args, ctx=ctx, **kwargs)
1280
+ return self.fn(*args, ctx=ctx)
766
1281
 
767
- class JSAssign(JSStmt):
768
- __slots__ = ("name", "value", "declare") # pyright: ignore[reportUnannotatedClassAttribute]
769
- name: str
770
- value: JSExpr
771
- declare: bool # when True emit 'let name = ...'
1282
+ @override
1283
+ def transpile_getattr(self, attr: str, ctx: Transpiler) -> Expr:
1284
+ label = self.name or "Transformer"
1285
+ raise TypeError(f"{label} cannot have attributes")
772
1286
 
773
- def __init__(self, name: str, value: JSExpr, declare: bool = False):
774
- self.name = name
775
- self.value = value
776
- self.declare = declare
1287
+ @override
1288
+ def transpile_subscript(self, key: ast.expr, ctx: Transpiler) -> Expr:
1289
+ label = self.name or "Transformer"
1290
+ raise TypeError(f"{label} cannot be subscripted")
777
1291
 
778
1292
  @override
779
- def emit(self) -> str:
780
- if self.declare:
781
- return f"let {self.name} = {self.value.emit()};"
782
- return f"{self.name} = {self.value.emit()};"
1293
+ def render(self) -> VDOMNode:
1294
+ label = self.name or "Transformer"
1295
+ raise TypeError(f"{label} cannot be rendered - must be called")
783
1296
 
784
1297
 
785
- class JSRaw(JSExpr):
786
- __slots__ = ("content",) # pyright: ignore[reportUnannotatedClassAttribute]
787
- is_primary: ClassVar[bool] = True
788
- content: str
1298
+ @overload
1299
+ def transformer(arg: str) -> Callable[[_F], _F]: ...
789
1300
 
790
- def __init__(self, content: str):
791
- self.content = content
792
1301
 
793
- @override
794
- def emit(self) -> str:
795
- return self.content
1302
+ @overload
1303
+ def transformer(arg: _F) -> _F: ...
796
1304
 
797
1305
 
798
- ###############################################################################
799
- # JSX AST (minimal)
800
- ###############################################################################
1306
+ def transformer(arg: str | _F) -> Callable[[_F], _F] | _F:
1307
+ """Decorator/helper for Transformer.
1308
+
1309
+ Usage:
1310
+ @transformer("len")
1311
+ def emit_len(x, *, ctx): ...
1312
+ or:
1313
+ emit_len = transformer(lambda x, *, ctx: ...)
1314
+
1315
+ Returns a Transformer, but the type signature lies and preserves
1316
+ the original function type.
1317
+ """
1318
+ if isinstance(arg, str):
801
1319
 
1320
+ def decorator(fn: _F) -> _F:
1321
+ return cast(_F, Transformer(fn, name=arg))
802
1322
 
803
- def _check_not_interpreted_mode(node_type: str) -> None:
804
- """Raise an error if we're in interpreted mode - JSX can't be eval'd."""
805
- if is_interpreted_mode():
806
- raise ValueError(
807
- f"{node_type} cannot be used in interpreted mode (as a prop or child value). "
808
- + "JSX syntax requires transpilation and cannot be evaluated at runtime. "
809
- + "Use standard VDOM elements (ps.div, ps.span, etc.) instead."
1323
+ return decorator
1324
+ elif isfunction(arg):
1325
+ # Use empty name for lambdas, function name for named functions
1326
+ name = "" if arg.__name__ == "<lambda>" else arg.__name__
1327
+ return cast(_F, Transformer(arg, name=name))
1328
+ else:
1329
+ raise TypeError(
1330
+ "transformer expects a function or string (for decorator usage)"
810
1331
  )
811
1332
 
812
1333
 
813
- def _escape_jsx_text(text: str) -> str:
814
- # Minimal escaping for text nodes
815
- return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1334
+ # =============================================================================
1335
+ # Statement Nodes
1336
+ # =============================================================================
816
1337
 
817
1338
 
818
- class JSXProp(JSExpr):
819
- __slots__ = ("name", "value") # pyright: ignore[reportUnannotatedClassAttribute]
820
- name: str
821
- value: JSExpr | None
1339
+ @dataclass(slots=True)
1340
+ class Return(Stmt):
1341
+ """JS return statement: return expr;"""
822
1342
 
823
- def __init__(self, name: str, value: JSExpr | None = None):
824
- self.name = name
825
- self.value = value
1343
+ value: Expr | None = None
826
1344
 
827
1345
  @override
828
- def emit(self) -> str:
829
- _check_not_interpreted_mode("JSXProp")
830
- if self.value is None:
831
- return self.name
832
- # Prefer compact string literal attribute when possible
833
- if isinstance(self.value, JSString):
834
- return f"{self.name}={self.value.emit()}"
835
- return self.name + "={" + self.value.emit() + "}"
1346
+ def emit(self, out: list[str]) -> None:
1347
+ out.append("return")
1348
+ if self.value is not None:
1349
+ out.append(" ")
1350
+ self.value.emit(out)
1351
+ out.append(";")
836
1352
 
837
1353
 
838
- class JSXSpreadProp(JSExpr):
839
- __slots__ = ("value",) # pyright: ignore[reportUnannotatedClassAttribute]
840
- value: JSExpr
1354
+ @dataclass(slots=True)
1355
+ class If(Stmt):
1356
+ """JS if statement: if (cond) { ... } else { ... }"""
841
1357
 
842
- def __init__(self, value: JSExpr):
843
- self.value = value
1358
+ cond: Expr
1359
+ then: Sequence[Stmt]
1360
+ else_: Sequence[Stmt] = ()
844
1361
 
845
1362
  @override
846
- def emit(self) -> str:
847
- _check_not_interpreted_mode("JSXSpreadProp")
848
- return f"{{...{self.value.emit()}}}"
1363
+ def emit(self, out: list[str]) -> None:
1364
+ out.append("if (")
1365
+ self.cond.emit(out)
1366
+ out.append(") {\n")
1367
+ for stmt in self.then:
1368
+ stmt.emit(out)
1369
+ out.append("\n")
1370
+ out.append("}")
1371
+ if self.else_:
1372
+ out.append(" else {\n")
1373
+ for stmt in self.else_:
1374
+ stmt.emit(out)
1375
+ out.append("\n")
1376
+ out.append("}")
1377
+
1378
+
1379
+ @dataclass(slots=True)
1380
+ class ForOf(Stmt):
1381
+ """JS for-of loop: for (const x of iter) { ... }
1382
+
1383
+ target can be a single name or array pattern for destructuring: [a, b]
1384
+ """
849
1385
 
1386
+ target: str
1387
+ iter: Expr
1388
+ body: Sequence[Stmt]
850
1389
 
851
- class JSXElement(JSExpr):
852
- __slots__ = ("tag", "props", "children") # pyright: ignore[reportUnannotatedClassAttribute]
853
- is_jsx: ClassVar[bool] = True
854
- is_primary: ClassVar[bool] = True
855
- tag: str | JSExpr
856
- props: Sequence[JSXProp | JSXSpreadProp]
857
- children: Sequence[str | JSExpr | JSXElement]
1390
+ @override
1391
+ def emit(self, out: list[str]) -> None:
1392
+ out.append("for (const ")
1393
+ out.append(self.target)
1394
+ out.append(" of ")
1395
+ self.iter.emit(out)
1396
+ out.append(") {\n")
1397
+ for stmt in self.body:
1398
+ stmt.emit(out)
1399
+ out.append("\n")
1400
+ out.append("}")
858
1401
 
859
- def __init__(
860
- self,
861
- tag: str | JSExpr,
862
- props: Sequence[JSXProp | JSXSpreadProp] = (),
863
- children: Sequence[str | JSExpr | JSXElement] = (),
864
- ):
865
- self.tag = tag
866
- self.props = props
867
- self.children = children
868
1402
 
869
- @override
870
- def emit(self) -> str:
871
- _check_not_interpreted_mode("JSXElement")
872
- tag_code = self.tag if isinstance(self.tag, str) else self.tag.emit()
873
- props_code = " ".join(p.emit() for p in self.props) if self.props else ""
874
- if not self.children:
875
- if props_code:
876
- return f"<{tag_code} {props_code} />"
877
- return f"<{tag_code} />"
878
- # Open tag
879
- open_tag = f"<{tag_code}>" if not props_code else f"<{tag_code} {props_code}>"
880
- # Children
881
- child_parts: list[str] = []
882
- for c in self.children:
883
- if isinstance(c, str):
884
- child_parts.append(_escape_jsx_text(c))
885
- elif isinstance(c, JSXElement) or (isinstance(c, JSExpr) and c.is_jsx):
886
- child_parts.append(c.emit())
887
- else:
888
- child_parts.append("{" + c.emit() + "}")
889
- inner = "".join(child_parts)
890
- return f"{open_tag}{inner}</{tag_code}>"
891
-
892
-
893
- class JSXFragment(JSExpr):
894
- __slots__ = ("children",) # pyright: ignore[reportUnannotatedClassAttribute]
895
- is_jsx: ClassVar[bool] = True
896
- is_primary: ClassVar[bool] = True
897
- children: Sequence[str | JSExpr | JSXElement]
898
-
899
- def __init__(self, children: Sequence[str | JSExpr | JSXElement] = ()):
900
- self.children = children
901
-
902
- @override
903
- def emit(self) -> str:
904
- _check_not_interpreted_mode("JSXFragment")
905
- if not self.children:
906
- return "<></>"
907
- parts: list[str] = []
908
- for c in self.children:
909
- if isinstance(c, str):
910
- parts.append(_escape_jsx_text(c))
911
- elif isinstance(c, JSXElement) or (isinstance(c, JSExpr) and c.is_jsx):
912
- parts.append(c.emit())
913
- else:
914
- parts.append("{" + c.emit() + "}")
915
- return "<>" + "".join(parts) + "</>"
1403
+ @dataclass(slots=True)
1404
+ class While(Stmt):
1405
+ """JS while loop: while (cond) { ... }"""
916
1406
 
1407
+ cond: Expr
1408
+ body: Sequence[Stmt]
917
1409
 
918
- class JSImport:
919
- __slots__ = ("src", "default", "named") # pyright: ignore[reportUnannotatedClassAttribute]
920
- src: str
921
- default: str | None
922
- named: list[str | tuple[str, str]]
1410
+ @override
1411
+ def emit(self, out: list[str]) -> None:
1412
+ out.append("while (")
1413
+ self.cond.emit(out)
1414
+ out.append(") {\n")
1415
+ for stmt in self.body:
1416
+ stmt.emit(out)
1417
+ out.append("\n")
1418
+ out.append("}")
923
1419
 
924
- def __init__(
925
- self,
926
- src: str,
927
- default: str | None = None,
928
- named: list[str | tuple[str, str]] | None = None,
929
- ):
930
- self.src = src
931
- self.default = default
932
- self.named = named if named is not None else []
933
-
934
- def emit(self) -> str:
935
- parts: list[str] = []
936
- if self.default:
937
- parts.append(self.default)
938
- if self.named:
939
- named_parts: list[str] = []
940
- for n in self.named:
941
- if isinstance(n, tuple):
942
- named_parts.append(f"{n[0]} as {n[1]}")
943
- else:
944
- named_parts.append(n)
945
- if named_parts:
946
- if self.default:
947
- parts.append(",")
948
- parts.append("{" + ", ".join(named_parts) + "}")
949
- return f"import {' '.join(parts)} from {JSString(self.src).emit()};"
950
-
951
-
952
- # -----------------------------
953
- # Precedence helpers
954
- # -----------------------------
955
-
956
- PRIMARY_PRECEDENCE = 20
957
-
958
-
959
- def op_precedence(op: str) -> int:
960
- # Higher number = binds tighter
961
- if op in {".", "[]", "()"}: # pseudo ops for primary contexts
962
- return PRIMARY_PRECEDENCE
963
- if op in {"!", "+u", "-u"}: # unary; we encode + and - as unary with +u/-u
964
- return 17
965
- if op in {"typeof", "await"}:
966
- return 17
967
- if op == "**":
968
- return 16
969
- if op in {"*", "/", "%"}:
970
- return 15
971
- if op in {"+", "-"}:
972
- return 14
973
- if op in {"<", "<=", ">", ">=", "===", "!=="}:
974
- return 12
975
- if op == "instanceof":
976
- return 12
977
- if op == "in":
978
- return 12
979
- if op == "&&":
980
- return 7
981
- if op == "||":
982
- return 6
983
- if op == "??":
984
- return 6
985
- if op == "?:": # ternary
986
- return 4
987
- if op == ",":
988
- return 1
989
- return 0
990
-
991
-
992
- def op_is_right_associative(op: str) -> bool:
993
- return op == "**"
994
-
995
-
996
- def expr_precedence(e: JSExpr) -> int:
997
- if isinstance(e, JSBinary):
998
- return op_precedence(e.op)
999
- if isinstance(e, JSUnary):
1000
- # Distinguish unary + and - from binary precedence table by tag
1001
- tag = "+u" if e.op == "+" else ("-u" if e.op == "-" else e.op)
1002
- return op_precedence(tag)
1003
- if isinstance(e, JSAwait):
1004
- return op_precedence("await")
1005
- if isinstance(e, JSTertiary):
1006
- return op_precedence("?:")
1007
- if isinstance(e, JSLogicalChain):
1008
- return op_precedence(e.op)
1009
- if isinstance(e, JSComma):
1010
- return op_precedence(",")
1011
- # Nullish now represented as JSBinary with op "??"; precedence resolved below
1012
- if isinstance(e, (JSMember, JSSubscript, JSCall, JSMemberCall, JSNew)):
1013
- return op_precedence(".")
1014
- # Primary expressions (identifiers, literals, containers) don't need parens
1015
- if e.is_primary:
1016
- return PRIMARY_PRECEDENCE
1017
- return 0
1018
-
1019
-
1020
- class JSBlock(JSStmt):
1021
- __slots__ = ("body",) # pyright: ignore[reportUnannotatedClassAttribute]
1022
- body: Sequence[JSStmt]
1023
-
1024
- def __init__(self, body: Sequence[JSStmt]):
1025
- self.body = body
1026
-
1027
- @override
1028
- def emit(self) -> str:
1029
- body_code = "\n".join(s.emit() for s in self.body)
1030
- return f"{{\n{body_code}\n}}"
1031
-
1032
-
1033
- class JSAugAssign(JSStmt):
1034
- __slots__ = ("name", "op", "value") # pyright: ignore[reportUnannotatedClassAttribute]
1035
- name: str
1036
- op: str
1037
- value: JSExpr
1038
1420
 
1039
- def __init__(self, name: str, op: str, value: JSExpr):
1040
- self.name = name
1041
- self.op = op
1042
- self.value = value
1421
+ @dataclass(slots=True)
1422
+ class Break(Stmt):
1423
+ """JS break statement."""
1043
1424
 
1044
1425
  @override
1045
- def emit(self) -> str:
1046
- return f"{self.name} {self.op}= {self.value.emit()};"
1426
+ def emit(self, out: list[str]) -> None:
1427
+ out.append("break;")
1047
1428
 
1048
1429
 
1049
- class JSConstAssign(JSStmt):
1050
- __slots__ = ("name", "value") # pyright: ignore[reportUnannotatedClassAttribute]
1051
- name: str
1052
- value: JSExpr
1053
-
1054
- def __init__(self, name: str, value: JSExpr):
1055
- self.name = name
1056
- self.value = value
1430
+ @dataclass(slots=True)
1431
+ class Continue(Stmt):
1432
+ """JS continue statement."""
1057
1433
 
1058
1434
  @override
1059
- def emit(self) -> str:
1060
- return f"const {self.name} = {self.value.emit()};"
1435
+ def emit(self, out: list[str]) -> None:
1436
+ out.append("continue;")
1061
1437
 
1062
1438
 
1063
- class JSSingleStmt(JSStmt):
1064
- __slots__ = ("expr",) # pyright: ignore[reportUnannotatedClassAttribute]
1065
- expr: JSExpr
1439
+ @dataclass(slots=True)
1440
+ class Assign(Stmt):
1441
+ """JS assignment: let x = expr; or x = expr; or x += expr;
1066
1442
 
1067
- def __init__(self, expr: JSExpr):
1068
- self.expr = expr
1443
+ declare: "let", "const", or None (reassignment)
1444
+ op: None for =, or "+", "-", etc. for augmented assignment
1445
+ """
1446
+
1447
+ target: str
1448
+ value: Expr
1449
+ declare: Lit["let", "const"] | None = None
1450
+ op: str | None = None # For augmented: +=, -=, etc.
1069
1451
 
1070
1452
  @override
1071
- def emit(self) -> str:
1072
- return f"{self.expr.emit()};"
1453
+ def emit(self, out: list[str]) -> None:
1454
+ if self.declare:
1455
+ out.append(self.declare)
1456
+ out.append(" ")
1457
+ out.append(self.target)
1458
+ if self.op:
1459
+ out.append(" ")
1460
+ out.append(self.op)
1461
+ out.append("= ")
1462
+ else:
1463
+ out.append(" = ")
1464
+ self.value.emit(out)
1465
+ out.append(";")
1073
1466
 
1074
1467
 
1075
- class JSMultiStmt(JSStmt):
1076
- __slots__ = ("stmts",) # pyright: ignore[reportUnannotatedClassAttribute]
1077
- stmts: Sequence[JSStmt]
1468
+ @dataclass(slots=True)
1469
+ class ExprStmt(Stmt):
1470
+ """JS expression statement: expr;"""
1078
1471
 
1079
- def __init__(self, stmts: Sequence[JSStmt]):
1080
- self.stmts = stmts
1472
+ expr: Expr
1081
1473
 
1082
1474
  @override
1083
- def emit(self) -> str:
1084
- return "\n".join(s.emit() for s in self.stmts)
1475
+ def emit(self, out: list[str]) -> None:
1476
+ self.expr.emit(out)
1477
+ out.append(";")
1085
1478
 
1086
1479
 
1087
- class JSIf(JSStmt):
1088
- __slots__ = ("test", "body", "orelse") # pyright: ignore[reportUnannotatedClassAttribute]
1089
- test: JSExpr
1090
- body: Sequence[JSStmt]
1091
- orelse: Sequence[JSStmt]
1480
+ @dataclass(slots=True)
1481
+ class Block(Stmt):
1482
+ """JS block: { ... } - a sequence of statements."""
1092
1483
 
1093
- def __init__(
1094
- self, test: JSExpr, body: Sequence[JSStmt], orelse: Sequence[JSStmt] = ()
1095
- ):
1096
- self.test = test
1097
- self.body = body
1098
- self.orelse = orelse
1484
+ body: Sequence[Stmt]
1099
1485
 
1100
1486
  @override
1101
- def emit(self) -> str:
1102
- body_code = "\n".join(s.emit() for s in self.body)
1103
- if not self.orelse:
1104
- return f"if ({self.test.emit()}){{\n{body_code}\n}}"
1105
- else_code = "\n".join(s.emit() for s in self.orelse)
1106
- return f"if ({self.test.emit()}){{\n{body_code}\n}} else {{\n{else_code}\n}}"
1487
+ def emit(self, out: list[str]) -> None:
1488
+ out.append("{\n")
1489
+ for stmt in self.body:
1490
+ stmt.emit(out)
1491
+ out.append("\n")
1492
+ out.append("}")
1107
1493
 
1108
1494
 
1109
- class JSForOf(JSStmt):
1110
- __slots__ = ("target", "iter_expr", "body") # pyright: ignore[reportUnannotatedClassAttribute]
1111
- target: str | list[str]
1112
- iter_expr: JSExpr
1113
- body: Sequence[JSStmt]
1495
+ @dataclass(slots=True)
1496
+ class StmtSequence(Stmt):
1497
+ """A sequence of statements without block braces.
1114
1498
 
1115
- def __init__(
1116
- self, target: str | list[str], iter_expr: JSExpr, body: Sequence[JSStmt]
1117
- ):
1118
- self.target = target
1119
- self.iter_expr = iter_expr
1120
- self.body = body
1499
+ Used for tuple unpacking where we need multiple statements
1500
+ but don't want to create a new scope.
1501
+ """
1502
+
1503
+ body: Sequence[Stmt]
1121
1504
 
1122
1505
  @override
1123
- def emit(self) -> str:
1124
- body_code = "\n".join(s.emit() for s in self.body)
1125
- target = self.target
1126
- if not isinstance(target, str):
1127
- target = f"[{', '.join(x for x in target)}]"
1128
- return f"for (const {target} of {self.iter_expr.emit()}){{\n{body_code}\n}}"
1506
+ def emit(self, out: list[str]) -> None:
1507
+ for i, stmt in enumerate(self.body):
1508
+ stmt.emit(out)
1509
+ if i < len(self.body) - 1:
1510
+ out.append("\n")
1129
1511
 
1130
1512
 
1131
- class JSWhile(JSStmt):
1132
- __slots__ = ("test", "body") # pyright: ignore[reportUnannotatedClassAttribute]
1133
- test: JSExpr
1134
- body: Sequence[JSStmt]
1513
+ @dataclass(slots=True)
1514
+ class Throw(Stmt):
1515
+ """JS throw statement: throw expr;"""
1135
1516
 
1136
- def __init__(self, test: JSExpr, body: Sequence[JSStmt]):
1137
- self.test = test
1138
- self.body = body
1517
+ value: Expr
1139
1518
 
1140
1519
  @override
1141
- def emit(self) -> str:
1142
- body_code = "\n".join(s.emit() for s in self.body)
1143
- return f"while ({self.test.emit()}){{\n{body_code}\n}}"
1520
+ def emit(self, out: list[str]) -> None:
1521
+ out.append("throw ")
1522
+ self.value.emit(out)
1523
+ out.append(";")
1524
+
1144
1525
 
1526
+ @dataclass(slots=True)
1527
+ class TryStmt(Stmt):
1528
+ """JS try/catch/finally statement.
1529
+
1530
+ try { body } catch (param) { handler } finally { finalizer }
1531
+ """
1145
1532
 
1146
- class JSBreak(JSStmt):
1147
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
1533
+ body: Sequence[Stmt]
1534
+ catch_param: str | None = None # None for bare except
1535
+ catch_body: Sequence[Stmt] | None = None
1536
+ finally_body: Sequence[Stmt] | None = None
1148
1537
 
1149
1538
  @override
1150
- def emit(self) -> str:
1151
- return "break;"
1539
+ def emit(self, out: list[str]) -> None:
1540
+ out.append("try {\n")
1541
+ for stmt in self.body:
1542
+ stmt.emit(out)
1543
+ out.append("\n")
1544
+ out.append("}")
1545
+
1546
+ if self.catch_body is not None:
1547
+ if self.catch_param:
1548
+ out.append(f" catch ({self.catch_param}) {{\n")
1549
+ else:
1550
+ out.append(" catch {\n")
1551
+ for stmt in self.catch_body:
1552
+ stmt.emit(out)
1553
+ out.append("\n")
1554
+ out.append("}")
1152
1555
 
1556
+ if self.finally_body is not None:
1557
+ out.append(" finally {\n")
1558
+ for stmt in self.finally_body:
1559
+ stmt.emit(out)
1560
+ out.append("\n")
1561
+ out.append("}")
1153
1562
 
1154
- class JSContinue(JSStmt):
1155
- __slots__ = () # pyright: ignore[reportUnannotatedClassAttribute]
1156
1563
 
1157
- @override
1158
- def emit(self) -> str:
1159
- return "continue;"
1564
+ @dataclass(slots=True)
1565
+ class Function(Expr):
1566
+ """JS function: function name(params) { ... } or async function ...
1160
1567
 
1568
+ For statement-bodied functions. Use Arrow for expression-bodied.
1569
+ """
1161
1570
 
1162
- def _mixes_nullish_and_logical(parent_op: str, child: JSExpr) -> bool:
1163
- if parent_op in {"&&", "||"} and isinstance(child, JSBinary) and child.op == "??":
1164
- return True
1165
- if parent_op == "??" and isinstance(child, JSLogicalChain):
1166
- return True
1167
- return False
1571
+ params: Sequence[str]
1572
+ body: Sequence[Stmt]
1573
+ name: str | None = None
1574
+ is_async: bool = False
1168
1575
 
1576
+ @override
1577
+ def emit(self, out: list[str]) -> None:
1578
+ if self.is_async:
1579
+ out.append("async ")
1580
+ out.append("function")
1581
+ if self.name:
1582
+ out.append(" ")
1583
+ out.append(self.name)
1584
+ out.append("(")
1585
+ out.append(", ".join(self.params))
1586
+ out.append(") {\n")
1587
+ for stmt in self.body:
1588
+ stmt.emit(out)
1589
+ out.append("\n")
1590
+ out.append("}")
1169
1591
 
1170
- def _emit_child_for_binary_like(
1171
- child: JSExpr, parent_op: str, side: str, force_paren: bool = False
1172
- ) -> str:
1173
- # side is one of: 'left', 'right', 'unary', 'chain'
1174
- code = child.emit()
1175
- if force_paren:
1176
- return f"({code})"
1177
- # Ternary as child should always be wrapped under binary-like contexts
1178
- if isinstance(child, JSTertiary):
1179
- return f"({code})"
1180
- # Explicit parens when mixing ?? with &&/||
1181
- if _mixes_nullish_and_logical(parent_op, child):
1182
- return f"({code})"
1183
- child_prec = expr_precedence(child)
1184
- parent_prec = op_precedence(parent_op)
1185
- if child_prec < parent_prec:
1186
- return f"({code})"
1187
- if child_prec == parent_prec:
1188
- # Handle associativity for exact same precedence buckets
1189
- if isinstance(child, JSBinary):
1190
- if op_is_right_associative(parent_op):
1191
- # Need parens on left child for same prec to preserve grouping
1192
- if side == "left":
1193
- return f"({code})"
1194
- else:
1195
- # Left-associative: protect right child when equal precedence
1196
- if side == "right":
1197
- return f"({code})"
1198
- if isinstance(child, JSLogicalChain):
1199
- # Same op chains don't need parens; different logical ops rely on precedence
1200
- if child.op != parent_op:
1201
- # '&&' has higher precedence than '||'; no parens needed for tighter child
1202
- # But if equal (shouldn't happen here), remain as-is
1203
- pass
1204
- # For other equal-precedence non-binary nodes, keep as-is
1205
- return code
1592
+ @override
1593
+ def render(self) -> VDOMNode:
1594
+ raise TypeError("Function cannot be rendered as VDOMExpr")
1595
+
1596
+
1597
+ Node: TypeAlias = Primitive | Expr | PulseNode
1598
+ Child: TypeAlias = Node | Iterable[Node]
1599
+ Children: TypeAlias = Sequence[Child]
1600
+ Prop: TypeAlias = Primitive | Expr
1601
+
1602
+
1603
+ # =============================================================================
1604
+ # Emit logic
1605
+ # =============================================================================
1606
+
1607
+
1608
+ Emittable: TypeAlias = Expr | Stmt
1609
+
1610
+
1611
+ def emit(node: Emittable) -> str:
1612
+ """Emit an expression or statement as JavaScript/JSX code."""
1613
+ out: list[str] = []
1614
+ node.emit(out)
1615
+ return "".join(out)
1616
+
1617
+
1618
+ # Operator precedence table (higher = binds tighter)
1619
+ _PRECEDENCE: dict[str, int] = {
1620
+ # Primary
1621
+ ".": 20,
1622
+ "[]": 20,
1623
+ "()": 20,
1624
+ # Unary
1625
+ "!": 17,
1626
+ "+u": 17,
1627
+ "-u": 17,
1628
+ "typeof": 17,
1629
+ "await": 17,
1630
+ # Exponentiation (right-assoc)
1631
+ "**": 16,
1632
+ # Multiplicative
1633
+ "*": 15,
1634
+ "/": 15,
1635
+ "%": 15,
1636
+ # Additive
1637
+ "+": 14,
1638
+ "-": 14,
1639
+ # Relational
1640
+ "<": 12,
1641
+ "<=": 12,
1642
+ ">": 12,
1643
+ ">=": 12,
1644
+ "===": 12,
1645
+ "!==": 12,
1646
+ "instanceof": 12,
1647
+ "in": 12,
1648
+ # Logical
1649
+ "&&": 7,
1650
+ "||": 6,
1651
+ "??": 6,
1652
+ # Ternary
1653
+ "?:": 4,
1654
+ # Comma
1655
+ ",": 1,
1656
+ }
1206
1657
 
1658
+ _RIGHT_ASSOC = {"**"}
1659
+
1660
+
1661
+ def _escape_string(s: str) -> str:
1662
+ """Escape for double-quoted JS string literals."""
1663
+ return (
1664
+ s.replace("\\", "\\\\")
1665
+ .replace('"', '\\"')
1666
+ .replace("\n", "\\n")
1667
+ .replace("\r", "\\r")
1668
+ .replace("\t", "\\t")
1669
+ .replace("\b", "\\b")
1670
+ .replace("\f", "\\f")
1671
+ .replace("\v", "\\v")
1672
+ .replace("\x00", "\\x00")
1673
+ .replace("\u2028", "\\u2028")
1674
+ .replace("\u2029", "\\u2029")
1675
+ )
1676
+
1677
+
1678
+ def _escape_template(s: str) -> str:
1679
+ """Escape for template literal strings."""
1680
+ return (
1681
+ s.replace("\\", "\\\\")
1682
+ .replace("`", "\\`")
1683
+ .replace("${", "\\${")
1684
+ .replace("\n", "\\n")
1685
+ .replace("\r", "\\r")
1686
+ .replace("\t", "\\t")
1687
+ .replace("\b", "\\b")
1688
+ .replace("\f", "\\f")
1689
+ .replace("\v", "\\v")
1690
+ .replace("\x00", "\\x00")
1691
+ .replace("\u2028", "\\u2028")
1692
+ .replace("\u2029", "\\u2029")
1693
+ )
1694
+
1695
+
1696
+ def _escape_jsx_text(s: str) -> str:
1697
+ """Escape text content for JSX."""
1698
+ return s.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
1699
+
1700
+
1701
+ def _escape_jsx_attr(s: str) -> str:
1702
+ """Escape attribute value for JSX."""
1703
+ return s.replace("&", "&amp;").replace('"', "&quot;")
1704
+
1705
+
1706
+ def _emit_paren(node: Expr, parent_op: str, side: str, out: list[str]) -> None:
1707
+ """Emit child with parens if needed for precedence."""
1708
+ # Ternary as child of binary always needs parens
1709
+ needs_parens = False
1710
+ if isinstance(node, Ternary):
1711
+ needs_parens = True
1712
+ else:
1713
+ child_prec = node.precedence()
1714
+ parent_prec = _PRECEDENCE.get(parent_op, 0)
1715
+ if child_prec < parent_prec:
1716
+ needs_parens = True
1717
+ elif child_prec == parent_prec and isinstance(node, Binary):
1718
+ # Handle associativity
1719
+ if parent_op in _RIGHT_ASSOC:
1720
+ needs_parens = side == "left"
1721
+ else:
1722
+ needs_parens = side == "right"
1207
1723
 
1208
- def _emit_child_for_primary(expr: JSExpr) -> str:
1209
- code = expr.emit()
1210
- if expr_precedence(expr) < PRIMARY_PRECEDENCE or isinstance(expr, JSTertiary):
1211
- return f"({code})"
1212
- return code
1724
+ if needs_parens:
1725
+ out.append("(")
1726
+ node.emit(out)
1727
+ out.append(")")
1728
+ else:
1729
+ node.emit(out)
1213
1730
 
1214
1731
 
1215
- def is_primary(expr: JSExpr):
1216
- return isinstance(expr, (JSNumber, JSString, JSUndefined, JSNull, JSIdentifier))
1732
+ def _emit_primary(node: Expr, out: list[str]) -> None:
1733
+ """Emit with parens if not primary precedence."""
1734
+ if node.precedence() < 20 or isinstance(node, Ternary):
1735
+ out.append("(")
1736
+ node.emit(out)
1737
+ out.append(")")
1738
+ else:
1739
+ node.emit(out)
1740
+
1741
+
1742
+ def _emit_value(value: Any, out: list[str]) -> None:
1743
+ """Emit a Python value as JavaScript literal."""
1744
+ if value is None:
1745
+ out.append("null")
1746
+ elif isinstance(value, bool):
1747
+ out.append("true" if value else "false")
1748
+ elif isinstance(value, str):
1749
+ out.append('"')
1750
+ out.append(_escape_string(value))
1751
+ out.append('"')
1752
+ elif isinstance(value, (int, float)):
1753
+ out.append(str(value))
1754
+ elif isinstance(value, dt.datetime):
1755
+ out.append("new Date(")
1756
+ out.append(str(int(value.timestamp() * 1000)))
1757
+ out.append(")")
1758
+ elif isinstance(value, list):
1759
+ out.append("[")
1760
+ for i, v in enumerate(value): # pyright: ignore[reportUnknownArgumentType]
1761
+ if i > 0:
1762
+ out.append(", ")
1763
+ _emit_value(v, out)
1764
+ out.append("]")
1765
+ elif isinstance(value, dict):
1766
+ out.append("{")
1767
+ for i, (k, v) in enumerate(value.items()): # pyright: ignore[reportUnknownArgumentType]
1768
+ if i > 0:
1769
+ out.append(", ")
1770
+ out.append('"')
1771
+ out.append(_escape_string(str(k))) # pyright: ignore[reportUnknownArgumentType]
1772
+ out.append('": ')
1773
+ _emit_value(v, out)
1774
+ out.append("}")
1775
+ elif isinstance(value, set):
1776
+ out.append("new Set([")
1777
+ for i, v in enumerate(value): # pyright: ignore[reportUnknownArgumentType]
1778
+ if i > 0:
1779
+ out.append(", ")
1780
+ _emit_value(v, out)
1781
+ out.append("])")
1782
+ else:
1783
+ raise TypeError(f"Cannot emit {type(value).__name__} as JavaScript")
1784
+
1785
+
1786
+ def _emit_jsx_prop(name: str, value: Prop, out: list[str]) -> None:
1787
+ """Emit a single JSX prop."""
1788
+ # Spread props
1789
+ if isinstance(value, Spread):
1790
+ out.append("{...")
1791
+ value.expr.emit(out)
1792
+ out.append("}")
1793
+ return
1794
+ # Expression nodes
1795
+ if isinstance(value, Expr):
1796
+ # String literals can use compact form
1797
+ if isinstance(value, Literal) and isinstance(value.value, str):
1798
+ out.append(name)
1799
+ out.append('="')
1800
+ out.append(_escape_jsx_attr(value.value))
1801
+ out.append('"')
1802
+ else:
1803
+ out.append(name)
1804
+ out.append("={")
1805
+ value.emit(out)
1806
+ out.append("}")
1807
+ return
1808
+ # Primitives
1809
+ if value is None:
1810
+ out.append(name)
1811
+ out.append("={null}")
1812
+ return
1813
+ if isinstance(value, bool):
1814
+ out.append(name)
1815
+ out.append("={true}" if value else "={false}")
1816
+ return
1817
+ if isinstance(value, str):
1818
+ out.append(name)
1819
+ out.append('="')
1820
+ out.append(_escape_jsx_attr(value))
1821
+ out.append('"')
1822
+ return
1823
+ if isinstance(value, (int, float)):
1824
+ out.append(name)
1825
+ out.append("={")
1826
+ out.append(str(value))
1827
+ out.append("}")
1828
+ return
1829
+ # Value
1830
+ if isinstance(value, Value):
1831
+ out.append(name)
1832
+ out.append("={")
1833
+ _emit_value(value.value, out)
1834
+ out.append("}")
1835
+ return
1836
+ # Nested Element (render prop)
1837
+ if isinstance(value, Element):
1838
+ out.append(name)
1839
+ out.append("={")
1840
+ value.emit(out)
1841
+ out.append("}")
1842
+ return
1843
+ # Callable - error
1844
+ if callable(value):
1845
+ raise TypeError("Cannot emit callable in transpile context")
1846
+ # Fallback for other data
1847
+ out.append(name)
1848
+ out.append("={")
1849
+ _emit_value(value, out)
1850
+ out.append("}")
1851
+
1852
+
1853
+ def _emit_jsx_child(child: Node, out: list[str]) -> None:
1854
+ """Emit a single JSX child."""
1855
+ # Primitives
1856
+ if child is None or isinstance(child, bool):
1857
+ return # React ignores None/bool
1858
+ if isinstance(child, str):
1859
+ out.append(_escape_jsx_text(child))
1860
+ return
1861
+ if isinstance(child, (int, float)):
1862
+ out.append("{")
1863
+ out.append(str(child))
1864
+ out.append("}")
1865
+ return
1866
+ if isinstance(child, dt.datetime):
1867
+ out.append("{")
1868
+ _emit_value(child, out)
1869
+ out.append("}")
1870
+ return
1871
+ # PulseNode - error
1872
+ if isinstance(child, PulseNode):
1873
+ fn_name = getattr(child.fn, "__name__", "unknown")
1874
+ raise TypeError(
1875
+ f"Cannot transpile PulseNode '{fn_name}'. "
1876
+ + "Server components must be rendered, not transpiled."
1877
+ )
1878
+ # Element - recurse
1879
+ if isinstance(child, Element):
1880
+ child.emit(out)
1881
+ return
1882
+ # Spread - emit as {expr} without the spread operator (arrays are already iterable in JSX)
1883
+ if isinstance(child, Spread):
1884
+ out.append("{")
1885
+ child.expr.emit(out)
1886
+ out.append("}")
1887
+ return
1888
+ # Expr
1889
+ if isinstance(child, Expr):
1890
+ out.append("{")
1891
+ child.emit(out)
1892
+ out.append("}")
1893
+ return
1894
+ # Value
1895
+ if isinstance(child, Value):
1896
+ out.append("{")
1897
+ _emit_value(child.value, out)
1898
+ out.append("}")
1899
+ return
1900
+ raise TypeError(f"Cannot emit {type(child).__name__} as JSX child")