pulse-framework 0.1.46__py3-none-any.whl → 0.1.48__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.
- pulse/__init__.py +9 -23
- pulse/app.py +6 -25
- pulse/cli/processes.py +1 -0
- pulse/codegen/codegen.py +43 -88
- pulse/codegen/js.py +35 -5
- pulse/codegen/templates/route.py +341 -254
- pulse/form.py +1 -1
- pulse/helpers.py +51 -27
- pulse/hooks/core.py +2 -2
- pulse/hooks/effects.py +1 -1
- pulse/hooks/init.py +2 -1
- pulse/hooks/setup.py +1 -1
- pulse/hooks/stable.py +2 -2
- pulse/hooks/states.py +2 -2
- pulse/html/props.py +3 -2
- pulse/html/tags.py +135 -0
- pulse/html/tags.pyi +4 -0
- pulse/js/__init__.py +110 -0
- pulse/js/__init__.pyi +95 -0
- pulse/js/_types.py +297 -0
- pulse/js/array.py +253 -0
- pulse/js/console.py +47 -0
- pulse/js/date.py +113 -0
- pulse/js/document.py +138 -0
- pulse/js/error.py +139 -0
- pulse/js/json.py +62 -0
- pulse/js/map.py +84 -0
- pulse/js/math.py +66 -0
- pulse/js/navigator.py +76 -0
- pulse/js/number.py +54 -0
- pulse/js/object.py +173 -0
- pulse/js/promise.py +150 -0
- pulse/js/regexp.py +54 -0
- pulse/js/set.py +109 -0
- pulse/js/string.py +35 -0
- pulse/js/weakmap.py +50 -0
- pulse/js/weakset.py +45 -0
- pulse/js/window.py +199 -0
- pulse/messages.py +22 -3
- pulse/proxy.py +21 -8
- pulse/react_component.py +167 -14
- pulse/reactive_extensions.py +5 -5
- pulse/render_session.py +144 -34
- pulse/renderer.py +80 -115
- pulse/routing.py +1 -18
- pulse/transpiler/__init__.py +131 -0
- pulse/transpiler/builtins.py +731 -0
- pulse/transpiler/constants.py +110 -0
- pulse/transpiler/context.py +26 -0
- pulse/transpiler/errors.py +2 -0
- pulse/transpiler/function.py +250 -0
- pulse/transpiler/ids.py +16 -0
- pulse/transpiler/imports.py +409 -0
- pulse/transpiler/js_module.py +274 -0
- pulse/transpiler/modules/__init__.py +30 -0
- pulse/transpiler/modules/asyncio.py +38 -0
- pulse/transpiler/modules/json.py +20 -0
- pulse/transpiler/modules/math.py +320 -0
- pulse/transpiler/modules/re.py +466 -0
- pulse/transpiler/modules/tags.py +268 -0
- pulse/transpiler/modules/typing.py +59 -0
- pulse/transpiler/nodes.py +1216 -0
- pulse/transpiler/py_module.py +119 -0
- pulse/transpiler/transpiler.py +938 -0
- pulse/transpiler/utils.py +4 -0
- pulse/vdom.py +112 -6
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/METADATA +1 -1
- pulse_framework-0.1.48.dist-info/RECORD +119 -0
- pulse/codegen/imports.py +0 -204
- pulse/css.py +0 -155
- pulse_framework-0.1.46.dist-info/RECORD +0 -80
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.46.dist-info → pulse_framework-0.1.48.dist-info}/entry_points.txt +0 -0
pulse/js/window.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Browser window global Any.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from pulse.js import window
|
|
5
|
+
window.alert("Hello!") # -> window.alert("Hello!")
|
|
6
|
+
window.innerWidth # -> window.innerWidth
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from collections.abc import Callable as _Callable
|
|
10
|
+
from typing import Any as _Any
|
|
11
|
+
|
|
12
|
+
from pulse.js._types import Element as _Element
|
|
13
|
+
from pulse.js._types import Selection as _Selection
|
|
14
|
+
from pulse.transpiler.js_module import register_js_module as _register_js_module
|
|
15
|
+
|
|
16
|
+
# Dimensions
|
|
17
|
+
innerWidth: int
|
|
18
|
+
innerHeight: int
|
|
19
|
+
outerWidth: int
|
|
20
|
+
outerHeight: int
|
|
21
|
+
|
|
22
|
+
# Scroll position
|
|
23
|
+
scrollX: float
|
|
24
|
+
scrollY: float
|
|
25
|
+
pageXOffset: float # Alias for scrollX
|
|
26
|
+
pageYOffset: float # Alias for scrollY
|
|
27
|
+
|
|
28
|
+
# Screen information
|
|
29
|
+
devicePixelRatio: float
|
|
30
|
+
|
|
31
|
+
# Location and history (typed as Any since they're complex interfaces)
|
|
32
|
+
location: _Any
|
|
33
|
+
history: _Any
|
|
34
|
+
navigator: _Any
|
|
35
|
+
document: _Any
|
|
36
|
+
|
|
37
|
+
# Storage
|
|
38
|
+
localStorage: _Any
|
|
39
|
+
sessionStorage: _Any
|
|
40
|
+
|
|
41
|
+
# Performance
|
|
42
|
+
performance: _Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# Dialog methods
|
|
46
|
+
def alert(message: str = "") -> None:
|
|
47
|
+
"""Display an alert dialog with the given message."""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def confirm(message: str = "") -> bool:
|
|
52
|
+
"""Display a confirmation dialog. Returns True if user clicks OK."""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def prompt(message: str = "", default: str = "") -> str | None:
|
|
57
|
+
"""Display a prompt dialog. Returns input or None if cancelled."""
|
|
58
|
+
...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Scroll methods
|
|
62
|
+
def scrollTo(x: float | dict[str, float], y: float | None = None) -> None:
|
|
63
|
+
"""Scroll to the given position."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def scrollBy(x: float | dict[str, float], y: float | None = None) -> None:
|
|
68
|
+
"""Scroll by the given amount."""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def scroll(x: float | dict[str, float], y: float | None = None) -> None:
|
|
73
|
+
"""Alias for scrollTo."""
|
|
74
|
+
...
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# Selection
|
|
78
|
+
def getSelection() -> _Selection | None:
|
|
79
|
+
"""Return the current text selection."""
|
|
80
|
+
...
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def getComputedStyle(element: _Element, pseudoElt: str | None = None) -> _Any:
|
|
84
|
+
"""Return the computed style of an element."""
|
|
85
|
+
...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Focus
|
|
89
|
+
def focus() -> None:
|
|
90
|
+
"""Give focus to the window."""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def blur() -> None:
|
|
95
|
+
"""Remove focus from the window."""
|
|
96
|
+
...
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Open/close
|
|
100
|
+
def open(
|
|
101
|
+
url: str = "",
|
|
102
|
+
target: str = "_blank",
|
|
103
|
+
features: str = "",
|
|
104
|
+
) -> _Any | None:
|
|
105
|
+
"""Open a new window. Returns the new window Any or None."""
|
|
106
|
+
...
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def close() -> None:
|
|
110
|
+
"""Close the window (only works for windows opened by script)."""
|
|
111
|
+
...
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# Timers (these return timer IDs)
|
|
115
|
+
def setTimeout(handler: _Callable[..., None], timeout: int = 0, *args: _Any) -> int:
|
|
116
|
+
"""Schedule a function to run after a delay. Returns timer ID."""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def clearTimeout(timeoutId: int) -> None:
|
|
121
|
+
"""Cancel a timeout scheduled with setTimeout."""
|
|
122
|
+
...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def setInterval(handler: _Callable[..., None], timeout: int = 0, *args: _Any) -> int:
|
|
126
|
+
"""Schedule a function to run repeatedly. Returns timer ID."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def clearInterval(intervalId: int) -> None:
|
|
131
|
+
"""Cancel an interval scheduled with setInterval."""
|
|
132
|
+
...
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# Animation
|
|
136
|
+
def requestAnimationFrame(callback: _Callable[[float], None]) -> int:
|
|
137
|
+
"""Request a callback before the next repaint. Returns request ID."""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cancelAnimationFrame(requestId: int) -> None:
|
|
142
|
+
"""Cancel an animation frame request."""
|
|
143
|
+
...
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Event listeners
|
|
147
|
+
def addEventListener(
|
|
148
|
+
type: str,
|
|
149
|
+
listener: _Callable[..., None],
|
|
150
|
+
options: bool | dict[str, bool] | None = None,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Add an event listener to the window."""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def removeEventListener(
|
|
157
|
+
type: str,
|
|
158
|
+
listener: _Callable[..., None],
|
|
159
|
+
options: bool | dict[str, bool] | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
"""Remove an event listener from the window."""
|
|
162
|
+
...
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def dispatchEvent(event: _Any) -> bool:
|
|
166
|
+
"""Dispatch an event to the window."""
|
|
167
|
+
...
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Encoding
|
|
171
|
+
def atob(encoded: str) -> str:
|
|
172
|
+
"""Decode a base64 encoded string."""
|
|
173
|
+
...
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def btoa(data: str) -> str:
|
|
177
|
+
"""Encode a string as base64."""
|
|
178
|
+
...
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
# Misc
|
|
182
|
+
def matchMedia(query: str) -> _Any:
|
|
183
|
+
"""Return a MediaQueryList for the given media query."""
|
|
184
|
+
...
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def print_() -> None:
|
|
188
|
+
"""Open the print dialog."""
|
|
189
|
+
...
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def postMessage(
|
|
193
|
+
message: _Any, targetOrigin: str, transfer: list[_Any] | None = None
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Post a message to another window."""
|
|
196
|
+
...
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_register_js_module(name="window")
|
pulse/messages.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
from typing import Any, Literal, NotRequired, TypedDict
|
|
2
2
|
|
|
3
|
-
from pulse.renderer import VDOMOperation
|
|
4
3
|
from pulse.routing import RouteInfo
|
|
5
|
-
from pulse.vdom import VDOM
|
|
4
|
+
from pulse.vdom import VDOM, VDOMOperation
|
|
6
5
|
|
|
7
6
|
|
|
8
7
|
# ====================
|
|
@@ -14,7 +13,7 @@ class ServerInitMessage(TypedDict):
|
|
|
14
13
|
vdom: VDOM
|
|
15
14
|
callbacks: list[str]
|
|
16
15
|
render_props: list[str]
|
|
17
|
-
|
|
16
|
+
jsexpr_paths: list[str] # paths containing JS expressions
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class ServerUpdateMessage(TypedDict):
|
|
@@ -82,6 +81,15 @@ class ServerChannelResponseMessage(TypedDict):
|
|
|
82
81
|
error: NotRequired[Any]
|
|
83
82
|
|
|
84
83
|
|
|
84
|
+
class ServerJsExecMessage(TypedDict):
|
|
85
|
+
"""Execute JavaScript code on the client."""
|
|
86
|
+
|
|
87
|
+
type: Literal["js_exec"]
|
|
88
|
+
path: str
|
|
89
|
+
id: str
|
|
90
|
+
code: str
|
|
91
|
+
|
|
92
|
+
|
|
85
93
|
# ====================
|
|
86
94
|
# Client messages
|
|
87
95
|
# ====================
|
|
@@ -136,6 +144,15 @@ class ClientChannelResponseMessage(TypedDict):
|
|
|
136
144
|
error: NotRequired[Any]
|
|
137
145
|
|
|
138
146
|
|
|
147
|
+
class ClientJsResultMessage(TypedDict):
|
|
148
|
+
"""Result of client-side JS execution."""
|
|
149
|
+
|
|
150
|
+
type: Literal["js_result"]
|
|
151
|
+
id: str
|
|
152
|
+
result: Any
|
|
153
|
+
error: str | None
|
|
154
|
+
|
|
155
|
+
|
|
139
156
|
ServerChannelMessage = ServerChannelRequestMessage | ServerChannelResponseMessage
|
|
140
157
|
ServerMessage = (
|
|
141
158
|
ServerInitMessage
|
|
@@ -144,6 +161,7 @@ ServerMessage = (
|
|
|
144
161
|
| ServerApiCallMessage
|
|
145
162
|
| ServerNavigateToMessage
|
|
146
163
|
| ServerChannelMessage
|
|
164
|
+
| ServerJsExecMessage
|
|
147
165
|
)
|
|
148
166
|
|
|
149
167
|
|
|
@@ -153,6 +171,7 @@ ClientPulseMessage = (
|
|
|
153
171
|
| ClientNavigateMessage
|
|
154
172
|
| ClientUnmountMessage
|
|
155
173
|
| ClientApiResultMessage
|
|
174
|
+
| ClientJsResultMessage
|
|
156
175
|
)
|
|
157
176
|
ClientChannelMessage = ClientChannelRequestMessage | ClientChannelResponseMessage
|
|
158
177
|
ClientMessage = ClientPulseMessage | ClientChannelMessage
|
pulse/proxy.py
CHANGED
|
@@ -21,19 +21,32 @@ logger = logging.getLogger(__name__)
|
|
|
21
21
|
class ReactProxy:
|
|
22
22
|
"""
|
|
23
23
|
Handles proxying HTTP requests and WebSocket connections to React Router server.
|
|
24
|
+
|
|
25
|
+
In single-server mode, the Python server proxies unmatched routes to the React
|
|
26
|
+
dev server. This proxy rewrites URLs in responses to use the external server
|
|
27
|
+
address instead of the internal React server address.
|
|
24
28
|
"""
|
|
25
29
|
|
|
26
30
|
react_server_address: str
|
|
31
|
+
server_address: str
|
|
27
32
|
_client: httpx.AsyncClient | None
|
|
28
33
|
|
|
29
|
-
def __init__(self, react_server_address: str):
|
|
34
|
+
def __init__(self, react_server_address: str, server_address: str):
|
|
30
35
|
"""
|
|
31
36
|
Args:
|
|
32
|
-
react_server_address: React Router server
|
|
37
|
+
react_server_address: Internal React Router server URL (e.g., http://localhost:5173)
|
|
38
|
+
server_address: External server URL exposed to clients (e.g., http://localhost:8000)
|
|
33
39
|
"""
|
|
34
40
|
self.react_server_address = react_server_address
|
|
41
|
+
self.server_address = server_address
|
|
35
42
|
self._client = None
|
|
36
43
|
|
|
44
|
+
def _rewrite_url(self, url: str) -> str:
|
|
45
|
+
"""Rewrite internal React server URLs to external server address."""
|
|
46
|
+
if self.react_server_address in url:
|
|
47
|
+
return url.replace(self.react_server_address, self.server_address)
|
|
48
|
+
return url
|
|
49
|
+
|
|
37
50
|
@property
|
|
38
51
|
def client(self) -> httpx.AsyncClient:
|
|
39
52
|
"""Lazy initialization of HTTP client."""
|
|
@@ -190,12 +203,12 @@ class ReactProxy:
|
|
|
190
203
|
# Send request with streaming
|
|
191
204
|
r = await self.client.send(req, stream=True)
|
|
192
205
|
|
|
193
|
-
#
|
|
194
|
-
response_headers = {
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
206
|
+
# Rewrite headers that may contain internal React server URLs
|
|
207
|
+
response_headers: dict[str, str] = {}
|
|
208
|
+
for k, v in r.headers.items():
|
|
209
|
+
if k.lower() in ("location", "content-location"):
|
|
210
|
+
v = self._rewrite_url(v)
|
|
211
|
+
response_headers[k] = v
|
|
199
212
|
|
|
200
213
|
return StreamingResponse(
|
|
201
214
|
r.aiter_raw(),
|
pulse/react_component.py
CHANGED
|
@@ -13,6 +13,7 @@ from types import UnionType
|
|
|
13
13
|
from typing import (
|
|
14
14
|
Annotated,
|
|
15
15
|
Any,
|
|
16
|
+
ClassVar,
|
|
16
17
|
Generic,
|
|
17
18
|
Literal,
|
|
18
19
|
ParamSpec,
|
|
@@ -24,9 +25,18 @@ from typing import (
|
|
|
24
25
|
override,
|
|
25
26
|
)
|
|
26
27
|
|
|
27
|
-
from pulse.codegen.imports import Imported, ImportStatement
|
|
28
28
|
from pulse.helpers import Sentinel
|
|
29
29
|
from pulse.reactive_extensions import unwrap
|
|
30
|
+
from pulse.transpiler.errors import JSCompilationError
|
|
31
|
+
from pulse.transpiler.imports import Import
|
|
32
|
+
from pulse.transpiler.nodes import (
|
|
33
|
+
JSExpr,
|
|
34
|
+
JSMember,
|
|
35
|
+
JSSpread,
|
|
36
|
+
JSXElement,
|
|
37
|
+
JSXProp,
|
|
38
|
+
JSXSpreadProp,
|
|
39
|
+
)
|
|
30
40
|
from pulse.vdom import Child, Element, Node
|
|
31
41
|
|
|
32
42
|
T = TypeVar("T")
|
|
@@ -295,25 +305,112 @@ def default_fn_signature_without_children(
|
|
|
295
305
|
) -> Element: ...
|
|
296
306
|
|
|
297
307
|
|
|
298
|
-
|
|
308
|
+
# ----------------------------------------------------------------------------
|
|
309
|
+
# JSX transpilation helpers
|
|
310
|
+
# ----------------------------------------------------------------------------
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _build_jsx_props(kwargs: dict[str, Any]) -> list[JSXProp | JSXSpreadProp]:
|
|
314
|
+
"""Build JSX props list from kwargs dict.
|
|
315
|
+
|
|
316
|
+
Kwargs maps:
|
|
317
|
+
- "propName" -> value for named props
|
|
318
|
+
- "__spread_N" -> JSSpread(expr) for spread props
|
|
319
|
+
"""
|
|
320
|
+
props: list[JSXProp | JSXSpreadProp] = []
|
|
321
|
+
for key, value in kwargs.items():
|
|
322
|
+
if isinstance(value, JSSpread):
|
|
323
|
+
props.append(JSXSpreadProp(value.expr))
|
|
324
|
+
else:
|
|
325
|
+
props.append(JSXProp(key, JSExpr.of(value)))
|
|
326
|
+
return props
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _flatten_children(items: list[Any], out: list[JSExpr | JSXElement | str]) -> None:
|
|
330
|
+
"""Flatten arrays and handle spreads in children list."""
|
|
331
|
+
from pulse.transpiler.nodes import JSArray, JSString
|
|
332
|
+
|
|
333
|
+
for it in items:
|
|
334
|
+
# Convert raw values first
|
|
335
|
+
it = JSExpr.of(it) if not isinstance(it, JSExpr) else it
|
|
336
|
+
if isinstance(it, JSArray):
|
|
337
|
+
_flatten_children(list(it.elements), out)
|
|
338
|
+
elif isinstance(it, JSSpread):
|
|
339
|
+
out.append(it.expr)
|
|
340
|
+
elif isinstance(it, JSString):
|
|
341
|
+
out.append(it.value)
|
|
342
|
+
else:
|
|
343
|
+
out.append(it)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class ReactComponentCallExpr(JSExpr):
|
|
347
|
+
"""JSX call expression for a ReactComponent.
|
|
348
|
+
|
|
349
|
+
Created when a ReactComponent is called with props. Supports subscripting
|
|
350
|
+
to add children, producing the final JSXElement.
|
|
351
|
+
"""
|
|
352
|
+
|
|
353
|
+
is_jsx: ClassVar[bool] = True
|
|
354
|
+
component: "ReactComponent[...]"
|
|
355
|
+
props: tuple[JSXProp | JSXSpreadProp, ...]
|
|
356
|
+
children: tuple[str | JSExpr | JSXElement, ...]
|
|
357
|
+
|
|
358
|
+
def __init__(
|
|
359
|
+
self,
|
|
360
|
+
component: "ReactComponent[...]",
|
|
361
|
+
props: tuple[JSXProp | JSXSpreadProp, ...],
|
|
362
|
+
children: tuple[str | JSExpr | JSXElement, ...],
|
|
363
|
+
) -> None:
|
|
364
|
+
self.component = component
|
|
365
|
+
self.props = props
|
|
366
|
+
self.children = children
|
|
367
|
+
|
|
368
|
+
@override
|
|
369
|
+
def emit(self) -> str:
|
|
370
|
+
return JSXElement(self.component, self.props, self.children).emit()
|
|
371
|
+
|
|
372
|
+
@override
|
|
373
|
+
def emit_subscript(self, indices: list[Any]) -> JSExpr:
|
|
374
|
+
"""Handle Component(props...)[children] -> JSXElement."""
|
|
375
|
+
extra_children: list[JSExpr | JSXElement | str] = []
|
|
376
|
+
_flatten_children(indices, extra_children)
|
|
377
|
+
all_children = list(self.children) + extra_children
|
|
378
|
+
return JSXElement(self.component, self.props, all_children)
|
|
379
|
+
|
|
380
|
+
@override
|
|
381
|
+
def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
|
|
382
|
+
"""Calling an already-called component is an error."""
|
|
383
|
+
raise JSCompilationError(
|
|
384
|
+
f"Cannot call <{self.component.name}> - already called. "
|
|
385
|
+
+ "Use subscript for children: Component(props...)[children]"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class ReactComponent(JSExpr, Generic[P]):
|
|
299
390
|
"""
|
|
300
391
|
A React component that can be used within the UI tree.
|
|
301
392
|
Returns a function that creates mount point UITreeNode instances.
|
|
302
393
|
|
|
303
394
|
Args:
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
alias: Optional alias for the component in the registry
|
|
395
|
+
name: Name of the component (or "default" for default export)
|
|
396
|
+
src: Module path to import the component from
|
|
307
397
|
is_default: True if this is a default export, else named export
|
|
308
|
-
|
|
398
|
+
prop: Optional property name to access the component from the imported object
|
|
399
|
+
lazy: Whether to lazy load the component
|
|
400
|
+
version: Optional npm semver constraint for this component's package
|
|
401
|
+
prop_spec: Optional PropSpec for the component
|
|
402
|
+
fn_signature: Function signature to parse for props
|
|
403
|
+
extra_imports: Additional imports to include (CSS files, etc.)
|
|
309
404
|
|
|
310
405
|
Returns:
|
|
311
406
|
A function that creates Node instances with mount point tags
|
|
312
407
|
"""
|
|
313
408
|
|
|
409
|
+
import_: Import
|
|
314
410
|
props_spec: PropSpec
|
|
315
411
|
fn_signature: Callable[P, Element]
|
|
316
412
|
lazy: bool
|
|
413
|
+
_prop: str | None # Property access like AppShell.Header
|
|
317
414
|
|
|
318
415
|
def __init__(
|
|
319
416
|
self,
|
|
@@ -326,11 +423,14 @@ class ReactComponent(Generic[P], Imported):
|
|
|
326
423
|
version: str | None = None,
|
|
327
424
|
prop_spec: PropSpec | None = None,
|
|
328
425
|
fn_signature: Callable[P, Element] = default_signature,
|
|
329
|
-
extra_imports: tuple[
|
|
330
|
-
| list[ImportStatement]
|
|
331
|
-
| None = None,
|
|
426
|
+
extra_imports: tuple[Import, ...] | list[Import] | None = None,
|
|
332
427
|
):
|
|
333
|
-
|
|
428
|
+
# Create the Import directly (prop is stored separately on ReactComponent)
|
|
429
|
+
if is_default:
|
|
430
|
+
self.import_ = Import.default(name, src)
|
|
431
|
+
else:
|
|
432
|
+
self.import_ = Import.named(name, src)
|
|
433
|
+
self._prop = prop
|
|
334
434
|
|
|
335
435
|
# Build props_spec from fn_signature if provided and props not provided
|
|
336
436
|
if prop_spec:
|
|
@@ -347,10 +447,62 @@ class ReactComponent(Generic[P], Imported):
|
|
|
347
447
|
self.lazy = lazy
|
|
348
448
|
# Optional npm semver constraint for this component's package
|
|
349
449
|
self.version: str | None = version
|
|
350
|
-
# Additional
|
|
351
|
-
self.extra_imports: list[
|
|
450
|
+
# Additional imports to include in route where this component is used
|
|
451
|
+
self.extra_imports: list[Import] = list(extra_imports or [])
|
|
352
452
|
COMPONENT_REGISTRY.get().add(self)
|
|
353
453
|
|
|
454
|
+
@override
|
|
455
|
+
def emit(self) -> str:
|
|
456
|
+
if self.prop:
|
|
457
|
+
return JSMember(self.import_, self.prop).emit()
|
|
458
|
+
return self.import_.emit()
|
|
459
|
+
|
|
460
|
+
@override
|
|
461
|
+
def emit_call(self, args: list[Any], kwargs: dict[str, Any]) -> JSExpr:
|
|
462
|
+
"""Handle Component(props...) -> ReactComponentCallExpr."""
|
|
463
|
+
props_list = _build_jsx_props(kwargs)
|
|
464
|
+
children_list: list[JSExpr | JSXElement | str] = []
|
|
465
|
+
_flatten_children(args, children_list)
|
|
466
|
+
return ReactComponentCallExpr(self, tuple(props_list), tuple(children_list))
|
|
467
|
+
|
|
468
|
+
@override
|
|
469
|
+
def emit_subscript(self, indices: list[Any]) -> JSExpr:
|
|
470
|
+
"""Direct subscript on ReactComponent is not allowed.
|
|
471
|
+
|
|
472
|
+
Use Component(props...)[children] instead of Component[children].
|
|
473
|
+
"""
|
|
474
|
+
raise JSCompilationError(
|
|
475
|
+
f"Cannot subscript ReactComponent '{self.name}' directly. "
|
|
476
|
+
+ "Use Component(props...)[children] or Component()[children] instead."
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
@property
|
|
480
|
+
def name(self) -> str:
|
|
481
|
+
return self.import_.name
|
|
482
|
+
|
|
483
|
+
@property
|
|
484
|
+
def src(self) -> str:
|
|
485
|
+
return self.import_.src
|
|
486
|
+
|
|
487
|
+
@property
|
|
488
|
+
def is_default(self) -> bool:
|
|
489
|
+
return self.import_.is_default
|
|
490
|
+
|
|
491
|
+
@property
|
|
492
|
+
def prop(self) -> str | None:
|
|
493
|
+
return self._prop
|
|
494
|
+
|
|
495
|
+
@property
|
|
496
|
+
def expr(self) -> str:
|
|
497
|
+
"""Expression for the component in the registry and VDOM tags.
|
|
498
|
+
|
|
499
|
+
Uses the import's js_name (with unique ID suffix) to match the
|
|
500
|
+
unified registry on the client side.
|
|
501
|
+
"""
|
|
502
|
+
if self.prop:
|
|
503
|
+
return f"{self.import_.js_name}.{self.prop}"
|
|
504
|
+
return self.import_.js_name
|
|
505
|
+
|
|
354
506
|
@override
|
|
355
507
|
def __repr__(self) -> str:
|
|
356
508
|
default_part = ", default=True" if self.is_default else ""
|
|
@@ -359,7 +511,8 @@ class ReactComponent(Generic[P], Imported):
|
|
|
359
511
|
props_part = f", props_spec={self.props_spec!r}"
|
|
360
512
|
return f"ReactComponent(name='{self.name}', src='{self.src}'{prop_part}{default_part}{lazy_part}{props_part})"
|
|
361
513
|
|
|
362
|
-
|
|
514
|
+
@override
|
|
515
|
+
def __call__(self, *children: P.args, **props: P.kwargs) -> Node: # pyright: ignore[reportIncompatibleMethodOverride]
|
|
363
516
|
key = props.get("key")
|
|
364
517
|
if key is not None and not isinstance(key, str):
|
|
365
518
|
raise ValueError("key must be a string or None")
|
|
@@ -806,7 +959,7 @@ def react_component(
|
|
|
806
959
|
is_default: bool = False,
|
|
807
960
|
lazy: bool = False,
|
|
808
961
|
version: str | None = None,
|
|
809
|
-
extra_imports: list[
|
|
962
|
+
extra_imports: list[Import] | None = None,
|
|
810
963
|
) -> Callable[[Callable[P, None] | Callable[P, Element]], ReactComponent[P]]:
|
|
811
964
|
"""
|
|
812
965
|
Decorator to define a React component wrapper. The decorated function is
|
pulse/reactive_extensions.py
CHANGED
|
@@ -45,7 +45,7 @@ class SupportsKeysAndGetItem(Protocol[T1, T2_co]):
|
|
|
45
45
|
|
|
46
46
|
# Return an iterable view that subscribes to per-key signals during iteration
|
|
47
47
|
class ReactiveDictItems(Generic[T1, T2]):
|
|
48
|
-
__slots__
|
|
48
|
+
__slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
49
49
|
_host: ReactiveDict[T1, T2]
|
|
50
50
|
|
|
51
51
|
def __init__(self, host: ReactiveDict[T1, T2]) -> None:
|
|
@@ -60,7 +60,7 @@ class ReactiveDictItems(Generic[T1, T2]):
|
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
class ReactiveDictValues(Generic[T1, T2]):
|
|
63
|
-
__slots__
|
|
63
|
+
__slots__ = ("_host",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
64
64
|
_host: ReactiveDict[T1, T2]
|
|
65
65
|
|
|
66
66
|
def __init__(self, host: ReactiveDict[T1, T2]) -> None:
|
|
@@ -84,7 +84,7 @@ class ReactiveDict(dict[T1, T2]):
|
|
|
84
84
|
- Iteration, membership checks, and len are reactive to structural changes
|
|
85
85
|
"""
|
|
86
86
|
|
|
87
|
-
__slots__
|
|
87
|
+
__slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
88
88
|
|
|
89
89
|
def __init__(self, initial: Mapping[T1, T2] | None = None) -> None:
|
|
90
90
|
super().__init__()
|
|
@@ -409,7 +409,7 @@ class ReactiveList(list[T1]):
|
|
|
409
409
|
- len() subscribes to structural changes
|
|
410
410
|
"""
|
|
411
411
|
|
|
412
|
-
__slots__
|
|
412
|
+
__slots__ = ("_signals", "_structure") # pyright: ignore[reportUnannotatedClassAttribute]
|
|
413
413
|
|
|
414
414
|
def __init__(self, initial: Iterable[T1] | None = None) -> None:
|
|
415
415
|
super().__init__()
|
|
@@ -645,7 +645,7 @@ class ReactiveSet(set[T1]):
|
|
|
645
645
|
- Iteration subscribes to membership signals for all elements
|
|
646
646
|
"""
|
|
647
647
|
|
|
648
|
-
__slots__
|
|
648
|
+
__slots__ = ("_signals",) # pyright: ignore[reportUnannotatedClassAttribute]
|
|
649
649
|
|
|
650
650
|
def __init__(self, initial: Iterable[T1] | None = None) -> None:
|
|
651
651
|
super().__init__()
|