pulse-framework 0.1.53__py3-none-any.whl → 0.1.55__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulse/__init__.py +3 -3
- pulse/app.py +34 -20
- pulse/code_analysis.py +38 -0
- pulse/codegen/codegen.py +18 -50
- pulse/codegen/templates/route.py +100 -56
- pulse/component.py +24 -6
- pulse/components/for_.py +17 -2
- pulse/cookies.py +38 -2
- pulse/env.py +4 -4
- pulse/hooks/init.py +174 -14
- pulse/hooks/state.py +105 -0
- pulse/js/__init__.py +12 -9
- pulse/js/obj.py +79 -0
- pulse/js/pulse.py +112 -0
- pulse/js/react.py +457 -0
- pulse/messages.py +13 -13
- pulse/proxy.py +18 -5
- pulse/render_session.py +282 -266
- pulse/renderer.py +36 -73
- pulse/serializer.py +5 -2
- pulse/transpiler/__init__.py +13 -0
- pulse/transpiler/assets.py +66 -0
- pulse/transpiler/builtins.py +0 -20
- pulse/transpiler/dynamic_import.py +131 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +29 -11
- pulse/transpiler/function.py +36 -5
- pulse/transpiler/imports.py +33 -27
- pulse/transpiler/js_module.py +73 -20
- pulse/transpiler/modules/pulse/tags.py +35 -15
- pulse/transpiler/nodes.py +121 -36
- pulse/transpiler/py_module.py +1 -1
- pulse/transpiler/react_component.py +4 -11
- pulse/transpiler/transpiler.py +32 -26
- pulse/user_session.py +10 -0
- pulse_framework-0.1.55.dist-info/METADATA +196 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/RECORD +39 -32
- pulse/hooks/states.py +0 -285
- pulse_framework-0.1.53.dist-info/METADATA +0 -18
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/WHEEL +0 -0
- {pulse_framework-0.1.53.dist-info → pulse_framework-0.1.55.dist-info}/entry_points.txt +0 -0
pulse/__init__.py
CHANGED
|
@@ -1286,9 +1286,9 @@ from pulse.hooks.stable import (
|
|
|
1286
1286
|
stable as stable,
|
|
1287
1287
|
)
|
|
1288
1288
|
|
|
1289
|
-
# Hooks -
|
|
1290
|
-
from pulse.hooks.
|
|
1291
|
-
from pulse.hooks.
|
|
1289
|
+
# Hooks - State
|
|
1290
|
+
from pulse.hooks.state import StateHookState as StateHookState
|
|
1291
|
+
from pulse.hooks.state import state as state
|
|
1292
1292
|
from pulse.messages import ClientMessage as ClientMessage
|
|
1293
1293
|
from pulse.messages import Directives as Directives
|
|
1294
1294
|
from pulse.messages import Prerender as Prerender
|
pulse/app.py
CHANGED
|
@@ -21,6 +21,7 @@ from fastapi import FastAPI, HTTPException, Request, Response
|
|
|
21
21
|
from fastapi.middleware.cors import CORSMiddleware
|
|
22
22
|
from fastapi.responses import JSONResponse
|
|
23
23
|
from starlette.types import ASGIApp
|
|
24
|
+
from starlette.websockets import WebSocket
|
|
24
25
|
|
|
25
26
|
from pulse.codegen.codegen import Codegen, CodegenConfig
|
|
26
27
|
from pulse.context import PULSE_CONTEXT, PulseContext
|
|
@@ -28,6 +29,7 @@ from pulse.cookies import (
|
|
|
28
29
|
Cookie,
|
|
29
30
|
CORSOptions,
|
|
30
31
|
compute_cookie_domain,
|
|
32
|
+
compute_cookie_secure,
|
|
31
33
|
cors_options,
|
|
32
34
|
session_cookie,
|
|
33
35
|
)
|
|
@@ -364,6 +366,8 @@ class App:
|
|
|
364
366
|
# Compute cookie domain from deployment/server address if not explicitly provided
|
|
365
367
|
if self.cookie.domain is None:
|
|
366
368
|
self.cookie.domain = compute_cookie_domain(self.mode, self.server_address)
|
|
369
|
+
if self.cookie.secure is None:
|
|
370
|
+
self.cookie.secure = compute_cookie_secure(self.env, self.server_address)
|
|
367
371
|
|
|
368
372
|
# Add CORS middleware (configurable/overridable)
|
|
369
373
|
if self.cors is not None:
|
|
@@ -450,7 +454,7 @@ class App:
|
|
|
450
454
|
self._schedule_render_cleanup(render_id)
|
|
451
455
|
|
|
452
456
|
async def _prerender_one(path: str):
|
|
453
|
-
captured = render.
|
|
457
|
+
captured = render.prerender(path, route_info)
|
|
454
458
|
if captured["type"] == "vdom_init":
|
|
455
459
|
return Ok(captured)
|
|
456
460
|
if captured["type"] == "navigate_to":
|
|
@@ -578,20 +582,20 @@ class App:
|
|
|
578
582
|
server_address=server_address,
|
|
579
583
|
)
|
|
580
584
|
|
|
585
|
+
# In dev mode, proxy WebSocket connections to React Router (e.g. Vite HMR)
|
|
586
|
+
# Socket.IO handles /socket.io/ at ASGI level before reaching FastAPI
|
|
587
|
+
if self.env == "dev":
|
|
588
|
+
|
|
589
|
+
@self.fastapi.websocket("/{path:path}")
|
|
590
|
+
async def websocket_proxy(websocket: WebSocket, path: str): # pyright: ignore[reportUnusedFunction]
|
|
591
|
+
await proxy_handler.proxy_websocket(websocket)
|
|
592
|
+
|
|
581
593
|
@self.fastapi.api_route(
|
|
582
594
|
"/{path:path}",
|
|
583
595
|
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"],
|
|
584
596
|
include_in_schema=False,
|
|
585
597
|
)
|
|
586
598
|
async def proxy_catch_all(request: Request, path: str): # pyright: ignore[reportUnusedFunction]
|
|
587
|
-
# Skip WebSocket upgrades outside the Vite dev server (handled by Socket.IO)
|
|
588
|
-
is_websocket_upgrade = (
|
|
589
|
-
request.headers.get("upgrade", "").lower() == "websocket"
|
|
590
|
-
)
|
|
591
|
-
is_vite_dev_server = self.env == "dev" and request.url.path == "/"
|
|
592
|
-
if is_websocket_upgrade and not is_vite_dev_server:
|
|
593
|
-
raise HTTPException(status_code=404, detail="Not found")
|
|
594
|
-
|
|
595
599
|
# Proxy all unmatched HTTP requests to React Router
|
|
596
600
|
return await proxy_handler(request)
|
|
597
601
|
|
|
@@ -605,12 +609,14 @@ class App:
|
|
|
605
609
|
# Parse cookies from environ and ensure a session exists
|
|
606
610
|
cookie = self.cookie.get_from_socketio(environ)
|
|
607
611
|
if cookie is None:
|
|
608
|
-
raise ConnectionRefusedError()
|
|
612
|
+
raise ConnectionRefusedError("Socket connect missing cookie")
|
|
609
613
|
session = await self.get_or_create_session(cookie)
|
|
610
614
|
|
|
611
615
|
if not rid:
|
|
612
616
|
# Still refuse connections without a renderId
|
|
613
|
-
raise ConnectionRefusedError(
|
|
617
|
+
raise ConnectionRefusedError(
|
|
618
|
+
f"Socket connect missing render_id session={session.sid}"
|
|
619
|
+
)
|
|
614
620
|
|
|
615
621
|
# Allow reconnects where the provided renderId no longer exists by creating a new RenderSession
|
|
616
622
|
render = self.render_sessions.get(rid)
|
|
@@ -621,7 +627,10 @@ class App:
|
|
|
621
627
|
else:
|
|
622
628
|
owner = self._render_to_user.get(render.id)
|
|
623
629
|
if owner != session.sid:
|
|
624
|
-
raise ConnectionRefusedError(
|
|
630
|
+
raise ConnectionRefusedError(
|
|
631
|
+
f"Socket connect session mismatch render={render.id} "
|
|
632
|
+
+ f"owner={owner} session={session.sid}"
|
|
633
|
+
)
|
|
625
634
|
|
|
626
635
|
def on_message(message: ServerMessage):
|
|
627
636
|
payload = serialize(message)
|
|
@@ -733,14 +742,14 @@ class App:
|
|
|
733
742
|
self, render: RenderSession, session: UserSession, msg: ClientPulseMessage
|
|
734
743
|
) -> None:
|
|
735
744
|
async def _next() -> Ok[None]:
|
|
736
|
-
if msg["type"] == "
|
|
737
|
-
render.
|
|
738
|
-
elif msg["type"] == "
|
|
739
|
-
render.
|
|
745
|
+
if msg["type"] == "attach":
|
|
746
|
+
render.attach(msg["path"], msg["routeInfo"])
|
|
747
|
+
elif msg["type"] == "update":
|
|
748
|
+
render.update_route(msg["path"], msg["routeInfo"])
|
|
740
749
|
elif msg["type"] == "callback":
|
|
741
750
|
render.execute_callback(msg["path"], msg["callback"], msg["args"])
|
|
742
|
-
elif msg["type"] == "
|
|
743
|
-
render.
|
|
751
|
+
elif msg["type"] == "detach":
|
|
752
|
+
render.detach(msg["path"])
|
|
744
753
|
render.channels.remove_route(msg["path"])
|
|
745
754
|
elif msg["type"] == "api_result":
|
|
746
755
|
render.handle_api_result(dict(msg))
|
|
@@ -845,6 +854,11 @@ class App:
|
|
|
845
854
|
|
|
846
855
|
# Server-backed store path
|
|
847
856
|
assert isinstance(self.session_store, SessionStore)
|
|
857
|
+
cookie_secure = self.cookie.secure
|
|
858
|
+
if cookie_secure is None:
|
|
859
|
+
raise RuntimeError(
|
|
860
|
+
"Cookie.secure is not resolved. Ensure App.setup() ran before sessions."
|
|
861
|
+
)
|
|
848
862
|
if raw_cookie is not None:
|
|
849
863
|
sid = raw_cookie
|
|
850
864
|
data = await self.session_store.get(sid) or await self.session_store.create(
|
|
@@ -855,7 +869,7 @@ class App:
|
|
|
855
869
|
name=self.cookie.name,
|
|
856
870
|
value=sid,
|
|
857
871
|
domain=self.cookie.domain,
|
|
858
|
-
secure=
|
|
872
|
+
secure=cookie_secure,
|
|
859
873
|
samesite=self.cookie.samesite,
|
|
860
874
|
max_age_seconds=self.cookie.max_age_seconds,
|
|
861
875
|
)
|
|
@@ -871,7 +885,7 @@ class App:
|
|
|
871
885
|
name=self.cookie.name,
|
|
872
886
|
value=sid,
|
|
873
887
|
domain=self.cookie.domain,
|
|
874
|
-
secure=
|
|
888
|
+
secure=cookie_secure,
|
|
875
889
|
samesite=self.cookie.samesite,
|
|
876
890
|
max_age_seconds=self.cookie.max_age_seconds,
|
|
877
891
|
)
|
pulse/code_analysis.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Code analysis utilities for inspecting Python source."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import inspect
|
|
7
|
+
import textwrap
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def is_stub_function(fn: Callable[..., Any]) -> bool:
|
|
13
|
+
"""Check if function body is just ... or pass (no real implementation)."""
|
|
14
|
+
try:
|
|
15
|
+
source = inspect.getsource(fn)
|
|
16
|
+
tree = ast.parse(textwrap.dedent(source))
|
|
17
|
+
func_def = tree.body[0]
|
|
18
|
+
if not isinstance(func_def, ast.FunctionDef):
|
|
19
|
+
return False
|
|
20
|
+
body = func_def.body
|
|
21
|
+
# Skip docstring
|
|
22
|
+
if body and isinstance(body[0], ast.Expr):
|
|
23
|
+
if isinstance(body[0].value, ast.Constant) and isinstance(
|
|
24
|
+
body[0].value.value, str
|
|
25
|
+
):
|
|
26
|
+
body = body[1:]
|
|
27
|
+
if not body:
|
|
28
|
+
return True
|
|
29
|
+
if len(body) == 1:
|
|
30
|
+
stmt = body[0]
|
|
31
|
+
if isinstance(stmt, ast.Pass):
|
|
32
|
+
return True
|
|
33
|
+
if isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Constant):
|
|
34
|
+
if stmt.value.value is ...:
|
|
35
|
+
return True
|
|
36
|
+
return False
|
|
37
|
+
except (OSError, TypeError, SyntaxError):
|
|
38
|
+
return False
|
pulse/codegen/codegen.py
CHANGED
|
@@ -14,7 +14,7 @@ from pulse.codegen.templates.routes_ts import (
|
|
|
14
14
|
)
|
|
15
15
|
from pulse.env import env
|
|
16
16
|
from pulse.routing import Layout, Route, RouteTree
|
|
17
|
-
from pulse.transpiler import
|
|
17
|
+
from pulse.transpiler.assets import get_registered_assets
|
|
18
18
|
|
|
19
19
|
if TYPE_CHECKING:
|
|
20
20
|
from pulse.app import ConnectionStatusConfig
|
|
@@ -123,7 +123,7 @@ class Codegen:
|
|
|
123
123
|
self._copied_files = set()
|
|
124
124
|
|
|
125
125
|
# Copy all registered local files to the assets directory
|
|
126
|
-
|
|
126
|
+
self._copy_local_files()
|
|
127
127
|
|
|
128
128
|
# Keep track of all generated files
|
|
129
129
|
generated_files = set(
|
|
@@ -137,11 +137,7 @@ class Codegen:
|
|
|
137
137
|
self.generate_routes_ts(),
|
|
138
138
|
self.generate_routes_runtime_ts(),
|
|
139
139
|
*(
|
|
140
|
-
self.generate_route(
|
|
141
|
-
route,
|
|
142
|
-
server_address=server_address,
|
|
143
|
-
asset_import_paths=asset_import_paths,
|
|
144
|
-
)
|
|
140
|
+
self.generate_route(route, server_address=server_address)
|
|
145
141
|
for route in self.routes.flat_tree.values()
|
|
146
142
|
),
|
|
147
143
|
]
|
|
@@ -157,52 +153,27 @@ class Codegen:
|
|
|
157
153
|
except Exception as e:
|
|
158
154
|
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
159
155
|
|
|
160
|
-
def _copy_local_files(self) ->
|
|
161
|
-
"""Copy all registered local
|
|
156
|
+
def _copy_local_files(self) -> None:
|
|
157
|
+
"""Copy all registered local assets to the assets directory.
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
|
|
159
|
+
Uses the unified asset registry which tracks local files from both
|
|
160
|
+
Import objects and DynamicImport expressions.
|
|
165
161
|
"""
|
|
166
|
-
|
|
167
|
-
local_imports = [imp for imp in imports if imp.is_local]
|
|
162
|
+
assets = get_registered_assets()
|
|
168
163
|
|
|
169
|
-
if not
|
|
170
|
-
return
|
|
164
|
+
if not assets:
|
|
165
|
+
return
|
|
171
166
|
|
|
172
167
|
self.assets_folder.mkdir(parents=True, exist_ok=True)
|
|
173
|
-
asset_import_paths: dict[str, str] = {}
|
|
174
|
-
|
|
175
|
-
for imp in local_imports:
|
|
176
|
-
if imp.source_path is None:
|
|
177
|
-
continue
|
|
178
168
|
|
|
179
|
-
|
|
180
|
-
dest_path = self.assets_folder / asset_filename
|
|
169
|
+
for asset in assets:
|
|
170
|
+
dest_path = self.assets_folder / asset.asset_filename
|
|
181
171
|
|
|
182
172
|
# Copy file if source exists
|
|
183
|
-
if
|
|
184
|
-
shutil.copy2(
|
|
173
|
+
if asset.source_path.exists():
|
|
174
|
+
shutil.copy2(asset.source_path, dest_path)
|
|
185
175
|
self._copied_files.add(dest_path)
|
|
186
|
-
logger.debug(f"Copied {
|
|
187
|
-
|
|
188
|
-
# Store just the asset filename - the relative path is computed per-route
|
|
189
|
-
asset_import_paths[imp.src] = asset_filename
|
|
190
|
-
|
|
191
|
-
return asset_import_paths
|
|
192
|
-
|
|
193
|
-
def _compute_asset_prefix(self, route_file_path: str) -> str:
|
|
194
|
-
"""Compute the relative path prefix from a route file to the assets folder.
|
|
195
|
-
|
|
196
|
-
Args:
|
|
197
|
-
route_file_path: The route's file path (e.g., "users/_id_xxx.jsx")
|
|
198
|
-
|
|
199
|
-
Returns:
|
|
200
|
-
The relative path prefix (e.g., "../assets/" or "../../assets/")
|
|
201
|
-
"""
|
|
202
|
-
# Count directory depth: each "/" in the path adds one level
|
|
203
|
-
depth = route_file_path.count("/")
|
|
204
|
-
# Add 1 for the routes/ or layouts/ folder itself
|
|
205
|
-
return "../" * (depth + 1) + "assets/"
|
|
176
|
+
logger.debug(f"Copied {asset.source_path} -> {dest_path}")
|
|
206
177
|
|
|
207
178
|
def generate_layout_tsx(
|
|
208
179
|
self,
|
|
@@ -281,21 +252,18 @@ class Codegen:
|
|
|
281
252
|
self,
|
|
282
253
|
route: Route | Layout,
|
|
283
254
|
server_address: str,
|
|
284
|
-
asset_import_paths: dict[str, str],
|
|
285
255
|
):
|
|
286
256
|
route_file_path = route.file_path()
|
|
287
257
|
if isinstance(route, Layout):
|
|
288
258
|
output_path = self.output_folder / "layouts" / route_file_path
|
|
259
|
+
full_route_path = f"layouts/{route_file_path}"
|
|
289
260
|
else:
|
|
290
261
|
output_path = self.output_folder / "routes" / route_file_path
|
|
291
|
-
|
|
292
|
-
# Compute asset prefix based on route depth
|
|
293
|
-
asset_prefix = self._compute_asset_prefix(route_file_path)
|
|
262
|
+
full_route_path = f"routes/{route_file_path}"
|
|
294
263
|
|
|
295
264
|
content = generate_route(
|
|
296
265
|
path=route.unique_path(),
|
|
297
|
-
|
|
298
|
-
asset_prefix=asset_prefix,
|
|
266
|
+
route_file_path=full_route_path,
|
|
299
267
|
)
|
|
300
268
|
return write_file_if_changed(output_path, content)
|
|
301
269
|
|
pulse/codegen/templates/route.py
CHANGED
|
@@ -10,24 +10,29 @@ from pulse.transpiler import (
|
|
|
10
10
|
collect_function_graph,
|
|
11
11
|
emit,
|
|
12
12
|
get_registered_imports,
|
|
13
|
+
registered_constants,
|
|
13
14
|
registered_functions,
|
|
14
15
|
)
|
|
16
|
+
from pulse.transpiler.emit_context import EmitContext
|
|
15
17
|
from pulse.transpiler.function import AnyJsFunction
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
def _get_import_src(imp: Import) -> str:
|
|
21
|
+
"""Get the import source path, remapping to asset path for local imports."""
|
|
22
|
+
if imp.asset:
|
|
23
|
+
return imp.asset.import_path()
|
|
24
|
+
return imp.src
|
|
25
|
+
|
|
26
|
+
|
|
18
27
|
def _generate_import_statement(
|
|
19
28
|
src: str,
|
|
20
29
|
imports: list[Import],
|
|
21
|
-
asset_filenames: dict[str, str] | None = None,
|
|
22
|
-
asset_prefix: str = "../assets/",
|
|
23
30
|
) -> str:
|
|
24
31
|
"""Generate import statement(s) for a source module.
|
|
25
32
|
|
|
26
33
|
Args:
|
|
27
34
|
src: The original source path (may be remapped for local imports)
|
|
28
35
|
imports: List of Import objects for this source
|
|
29
|
-
asset_filenames: Mapping of original source paths to asset filenames
|
|
30
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
31
36
|
"""
|
|
32
37
|
default_imports: list[Import] = []
|
|
33
38
|
namespace_imports: list[Import] = []
|
|
@@ -53,8 +58,10 @@ def _generate_import_statement(
|
|
|
53
58
|
|
|
54
59
|
# Remap source path if this is a local import
|
|
55
60
|
import_src = src
|
|
56
|
-
|
|
57
|
-
|
|
61
|
+
for imp in imports:
|
|
62
|
+
if imp.asset:
|
|
63
|
+
import_src = imp.asset.import_path()
|
|
64
|
+
break
|
|
58
65
|
|
|
59
66
|
lines: list[str] = []
|
|
60
67
|
|
|
@@ -98,17 +105,11 @@ def _generate_import_statement(
|
|
|
98
105
|
return "\n".join(lines)
|
|
99
106
|
|
|
100
107
|
|
|
101
|
-
def _generate_imports_section(
|
|
102
|
-
imports: Sequence[Import],
|
|
103
|
-
asset_filenames: dict[str, str] | None = None,
|
|
104
|
-
asset_prefix: str = "../assets/",
|
|
105
|
-
) -> str:
|
|
108
|
+
def _generate_imports_section(imports: Sequence[Import]) -> str:
|
|
106
109
|
"""Generate the full imports section with deduplication and topological ordering.
|
|
107
110
|
|
|
108
111
|
Args:
|
|
109
|
-
imports: List of Import objects to generate
|
|
110
|
-
asset_filenames: Mapping of original source paths to asset filenames
|
|
111
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
112
|
+
imports: List of Import objects to generate (should be eager imports only)
|
|
112
113
|
"""
|
|
113
114
|
if not imports:
|
|
114
115
|
return ""
|
|
@@ -163,15 +164,46 @@ def _generate_imports_section(
|
|
|
163
164
|
|
|
164
165
|
lines: list[str] = []
|
|
165
166
|
for src in ordered:
|
|
166
|
-
stmt = _generate_import_statement(
|
|
167
|
-
src, grouped[src], asset_filenames, asset_prefix
|
|
168
|
-
)
|
|
167
|
+
stmt = _generate_import_statement(src, grouped[src])
|
|
169
168
|
if stmt:
|
|
170
169
|
lines.append(stmt)
|
|
171
170
|
|
|
172
171
|
return "\n".join(lines)
|
|
173
172
|
|
|
174
173
|
|
|
174
|
+
def _generate_lazy_imports_section(imports: Sequence[Import]) -> str:
|
|
175
|
+
"""Generate lazy import factories for code-splitting.
|
|
176
|
+
|
|
177
|
+
Lazy imports are emitted as factory functions compatible with React.lazy.
|
|
178
|
+
React.lazy requires factories that return { default: Component }.
|
|
179
|
+
|
|
180
|
+
For default imports: () => import("./Chart")
|
|
181
|
+
For named imports: () => import("./Chart").then(m => ({ default: m.LineChart }))
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
imports: List of lazy Import objects
|
|
185
|
+
"""
|
|
186
|
+
if not imports:
|
|
187
|
+
return ""
|
|
188
|
+
|
|
189
|
+
lines: list[str] = ["// Lazy imports"]
|
|
190
|
+
for imp in imports:
|
|
191
|
+
import_src = _get_import_src(imp)
|
|
192
|
+
|
|
193
|
+
if imp.is_default or imp.is_namespace:
|
|
194
|
+
# Default/namespace: () => import("module") - already has { default }
|
|
195
|
+
factory = f'() => import("{import_src}")'
|
|
196
|
+
else:
|
|
197
|
+
# Named: wrap in { default } for React.lazy compatibility
|
|
198
|
+
factory = (
|
|
199
|
+
f'() => import("{import_src}").then(m => ({{ default: m.{imp.name} }}))'
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
lines.append(f"const {imp.js_name} = {factory};")
|
|
203
|
+
|
|
204
|
+
return "\n".join(lines)
|
|
205
|
+
|
|
206
|
+
|
|
175
207
|
def _generate_constants_section(constants: Sequence[Constant]) -> str:
|
|
176
208
|
"""Generate the constants section."""
|
|
177
209
|
if not constants:
|
|
@@ -232,64 +264,76 @@ def _generate_registry_section(
|
|
|
232
264
|
|
|
233
265
|
def generate_route(
|
|
234
266
|
path: str,
|
|
235
|
-
|
|
236
|
-
asset_prefix: str = "../assets/",
|
|
267
|
+
route_file_path: str,
|
|
237
268
|
) -> str:
|
|
238
269
|
"""Generate a route file with all registered imports, functions, and components.
|
|
239
270
|
|
|
240
271
|
Args:
|
|
241
272
|
path: The route path (e.g., "/users/:id")
|
|
242
|
-
|
|
243
|
-
asset_prefix: Relative path prefix from route file to assets folder
|
|
273
|
+
route_file_path: Path from pulse root (e.g., "routes/users/index.tsx")
|
|
244
274
|
"""
|
|
245
|
-
|
|
246
|
-
|
|
275
|
+
with EmitContext(route_file_path=route_file_path):
|
|
276
|
+
# Add core Pulse imports
|
|
277
|
+
pulse_view_import = Import("PulseView", "pulse-ui-client")
|
|
278
|
+
|
|
279
|
+
# Collect function graph (constants + functions in dependency order)
|
|
280
|
+
fn_constants, funcs = collect_function_graph(registered_functions())
|
|
281
|
+
|
|
282
|
+
# Include all registered constants (not just function dependencies)
|
|
283
|
+
# This ensures constants used as component tags are included
|
|
284
|
+
fn_const_ids = {c.id for c in fn_constants}
|
|
285
|
+
all_constants = list(fn_constants)
|
|
286
|
+
for const in registered_constants():
|
|
287
|
+
if const.id not in fn_const_ids:
|
|
288
|
+
all_constants.append(const)
|
|
289
|
+
constants = all_constants
|
|
290
|
+
|
|
291
|
+
# Get all registered imports and split by lazy flag
|
|
292
|
+
all_imports = list(get_registered_imports())
|
|
293
|
+
eager_imports = [imp for imp in all_imports if not imp.lazy]
|
|
294
|
+
lazy_imports = [imp for imp in all_imports if imp.lazy]
|
|
295
|
+
|
|
296
|
+
# Generate output sections
|
|
297
|
+
output_parts: list[str] = []
|
|
298
|
+
|
|
299
|
+
# Eager imports (ES6 import statements)
|
|
300
|
+
imports_section = _generate_imports_section(eager_imports)
|
|
301
|
+
if imports_section:
|
|
302
|
+
output_parts.append(imports_section)
|
|
247
303
|
|
|
248
|
-
|
|
249
|
-
pulse_view_import = Import("PulseView", "pulse-ui-client")
|
|
250
|
-
|
|
251
|
-
# Collect function graph (constants + functions in dependency order)
|
|
252
|
-
constants, funcs = collect_function_graph(registered_functions())
|
|
253
|
-
|
|
254
|
-
# Get all registered imports
|
|
255
|
-
all_imports = list(get_registered_imports())
|
|
304
|
+
output_parts.append("")
|
|
256
305
|
|
|
257
|
-
|
|
258
|
-
|
|
306
|
+
# Lazy imports (factory functions)
|
|
307
|
+
lazy_section = _generate_lazy_imports_section(lazy_imports)
|
|
308
|
+
if lazy_section:
|
|
309
|
+
output_parts.append(lazy_section)
|
|
310
|
+
output_parts.append("")
|
|
259
311
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if imports_section:
|
|
264
|
-
output_parts.append(imports_section)
|
|
312
|
+
if constants:
|
|
313
|
+
output_parts.append(_generate_constants_section(constants))
|
|
314
|
+
output_parts.append("")
|
|
265
315
|
|
|
266
|
-
|
|
316
|
+
if funcs:
|
|
317
|
+
output_parts.append(_generate_functions_section(funcs))
|
|
318
|
+
output_parts.append("")
|
|
267
319
|
|
|
268
|
-
|
|
269
|
-
output_parts.append(
|
|
320
|
+
# Generate the unified registry including all imports, constants and functions
|
|
321
|
+
output_parts.append(_generate_registry_section(all_imports, constants, funcs))
|
|
270
322
|
output_parts.append("")
|
|
271
323
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
output_parts.append(""
|
|
275
|
-
|
|
276
|
-
# Generate the unified registry including all imports, constants and functions
|
|
277
|
-
output_parts.append(_generate_registry_section(all_imports, constants, funcs))
|
|
278
|
-
output_parts.append("")
|
|
279
|
-
|
|
280
|
-
# Route component
|
|
281
|
-
pulse_view_js = pulse_view_import.js_name
|
|
282
|
-
output_parts.append(f'''const path = "{path}";
|
|
324
|
+
# Route component
|
|
325
|
+
pulse_view_js = pulse_view_import.js_name
|
|
326
|
+
output_parts.append(f'''const path = "{path}";
|
|
283
327
|
|
|
284
328
|
export default function RouteComponent() {{
|
|
285
329
|
return (
|
|
286
330
|
<{pulse_view_js} key={{path}} registry={{__registry}} path={{path}} />
|
|
287
331
|
);
|
|
288
332
|
}}''')
|
|
289
|
-
|
|
333
|
+
output_parts.append("")
|
|
290
334
|
|
|
291
|
-
|
|
292
|
-
|
|
335
|
+
# Headers function
|
|
336
|
+
output_parts.append("""// Action and loader headers are not returned automatically
|
|
293
337
|
function hasAnyHeaders(headers) {
|
|
294
338
|
return [...headers].length > 0;
|
|
295
339
|
}
|
|
@@ -298,4 +342,4 @@ export function headers({ actionHeaders, loaderHeaders }) {
|
|
|
298
342
|
return hasAnyHeaders(actionHeaders) ? actionHeaders : loaderHeaders;
|
|
299
343
|
}""")
|
|
300
344
|
|
|
301
|
-
|
|
345
|
+
return "\n".join(output_parts)
|
pulse/component.py
CHANGED
|
@@ -4,6 +4,7 @@ from collections.abc import Callable
|
|
|
4
4
|
from inspect import Parameter, signature
|
|
5
5
|
from typing import Any, Generic, ParamSpec, TypeVar, overload, override
|
|
6
6
|
|
|
7
|
+
from pulse.code_analysis import is_stub_function
|
|
7
8
|
from pulse.hooks.init import rewrite_init_blocks
|
|
8
9
|
from pulse.transpiler.nodes import (
|
|
9
10
|
Children,
|
|
@@ -20,21 +21,38 @@ _T = TypeVar("_T")
|
|
|
20
21
|
|
|
21
22
|
|
|
22
23
|
class Component(Generic[P]):
|
|
23
|
-
|
|
24
|
+
_raw_fn: Callable[P, Any]
|
|
25
|
+
_fn: Callable[P, Any] | None
|
|
24
26
|
name: str
|
|
25
|
-
_takes_children: bool
|
|
27
|
+
_takes_children: bool | None
|
|
26
28
|
|
|
27
29
|
def __init__(self, fn: Callable[P, Any], name: str | None = None) -> None:
|
|
28
|
-
self.
|
|
30
|
+
self._raw_fn = fn
|
|
29
31
|
self.name = name or _infer_component_name(fn)
|
|
30
|
-
|
|
32
|
+
# Only lazy-init for stubs (avoid heavy work for JS module bindings)
|
|
33
|
+
# Real components need immediate rewrite for early error detection
|
|
34
|
+
if is_stub_function(fn):
|
|
35
|
+
self._fn = None
|
|
36
|
+
self._takes_children = None
|
|
37
|
+
else:
|
|
38
|
+
self._fn = rewrite_init_blocks(fn)
|
|
39
|
+
self._takes_children = _takes_children(fn)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def fn(self) -> Callable[P, Any]:
|
|
43
|
+
if self._fn is None:
|
|
44
|
+
self._fn = rewrite_init_blocks(self._raw_fn)
|
|
45
|
+
self._takes_children = _takes_children(self._raw_fn)
|
|
46
|
+
return self._fn
|
|
31
47
|
|
|
32
48
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> PulseNode:
|
|
33
49
|
key = kwargs.get("key")
|
|
34
50
|
if key is not None and not isinstance(key, str):
|
|
35
51
|
raise ValueError("key must be a string or None")
|
|
36
52
|
|
|
37
|
-
|
|
53
|
+
# Access self.fn to trigger lazy init (sets _takes_children)
|
|
54
|
+
_ = self.fn
|
|
55
|
+
if self._takes_children is True and args:
|
|
38
56
|
flattened = flatten_children(
|
|
39
57
|
args, # pyright: ignore[reportArgumentType]
|
|
40
58
|
parent_name=f"<{self.name}>",
|
|
@@ -46,7 +64,7 @@ class Component(Generic[P]):
|
|
|
46
64
|
|
|
47
65
|
@override
|
|
48
66
|
def __repr__(self) -> str:
|
|
49
|
-
return f"Component(name={self.name!r}, fn={_callable_qualname(self.
|
|
67
|
+
return f"Component(name={self.name!r}, fn={_callable_qualname(self._raw_fn)!r})"
|
|
50
68
|
|
|
51
69
|
@override
|
|
52
70
|
def __str__(self) -> str:
|
pulse/components/for_.py
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
from collections.abc import Callable, Iterable
|
|
2
2
|
from inspect import Parameter, signature
|
|
3
|
-
from typing import TypeVar, overload
|
|
3
|
+
from typing import TYPE_CHECKING, Any, TypeVar, overload
|
|
4
4
|
|
|
5
|
-
from pulse.transpiler.nodes import Element
|
|
5
|
+
from pulse.transpiler.nodes import Call, Element, Expr, Member, transformer
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pulse.transpiler.transpiler import Transpiler
|
|
6
9
|
|
|
7
10
|
T = TypeVar("T")
|
|
8
11
|
|
|
9
12
|
|
|
13
|
+
@transformer("For")
|
|
14
|
+
def emit_for(items: Any, fn: Any, *, ctx: "Transpiler") -> Expr:
|
|
15
|
+
"""For(items, fn) -> items.map(fn)"""
|
|
16
|
+
items_expr = ctx.emit_expr(items)
|
|
17
|
+
fn_expr = ctx.emit_expr(fn)
|
|
18
|
+
return Call(Member(items_expr, "map"), [fn_expr])
|
|
19
|
+
|
|
20
|
+
|
|
10
21
|
@overload
|
|
11
22
|
def For(items: Iterable[T], fn: Callable[[T], Element]) -> list[Element]: ...
|
|
12
23
|
|
|
@@ -40,3 +51,7 @@ def For(items: Iterable[T], fn: Callable[..., Element]) -> list[Element]:
|
|
|
40
51
|
if accepts_two:
|
|
41
52
|
return [fn(item, idx) for idx, item in enumerate(items)]
|
|
42
53
|
return [fn(item) for item in items]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Register For in EXPR_REGISTRY so it can be used in transpiled functions
|
|
57
|
+
Expr.register(For, emit_for)
|