pulse-framework 0.1.54__tar.gz → 0.1.55__tar.gz

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 (127) hide show
  1. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/PKG-INFO +1 -1
  2. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/pyproject.toml +1 -1
  3. pulse_framework-0.1.55/src/pulse/code_analysis.py +38 -0
  4. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/codegen.py +18 -50
  5. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/templates/route.py +100 -56
  6. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/component.py +24 -6
  7. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/__init__.py +1 -1
  8. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/react.py +114 -7
  9. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/__init__.py +13 -0
  10. pulse_framework-0.1.55/src/pulse/transpiler/assets.py +66 -0
  11. pulse_framework-0.1.55/src/pulse/transpiler/dynamic_import.py +131 -0
  12. pulse_framework-0.1.55/src/pulse/transpiler/emit_context.py +49 -0
  13. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/function.py +6 -2
  14. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/imports.py +33 -27
  15. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/js_module.py +64 -8
  16. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/react_component.py +4 -11
  17. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/transpiler.py +4 -0
  18. pulse_framework-0.1.54/src/pulse/js/react_dom.py +0 -30
  19. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/README.md +0 -0
  20. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/__init__.py +0 -0
  21. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/_examples.py +0 -0
  22. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/app.py +0 -0
  23. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/channel.py +0 -0
  24. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/__init__.py +0 -0
  25. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/cmd.py +0 -0
  26. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/dependencies.py +0 -0
  27. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/folder_lock.py +0 -0
  28. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/helpers.py +0 -0
  29. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/logging.py +0 -0
  30. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/models.py +0 -0
  31. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/packages.py +0 -0
  32. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/processes.py +0 -0
  33. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/secrets.py +0 -0
  34. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cli/uvicorn_log_config.py +0 -0
  35. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/__init__.py +0 -0
  36. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/js.py +0 -0
  37. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/templates/__init__.py +0 -0
  38. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/templates/layout.py +0 -0
  39. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/templates/routes_ts.py +0 -0
  40. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/codegen/utils.py +0 -0
  41. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/components/__init__.py +0 -0
  42. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/components/for_.py +0 -0
  43. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/components/if_.py +0 -0
  44. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/components/react_router.py +0 -0
  45. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/context.py +0 -0
  46. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/cookies.py +0 -0
  47. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/decorators.py +0 -0
  48. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/__init__.py +0 -0
  49. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/elements.py +0 -0
  50. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/events.py +0 -0
  51. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/props.py +0 -0
  52. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/svg.py +0 -0
  53. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/tags.py +0 -0
  54. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/dom/tags.pyi +0 -0
  55. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/env.py +0 -0
  56. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/form.py +0 -0
  57. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/helpers.py +0 -0
  58. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/__init__.py +0 -0
  59. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/core.py +0 -0
  60. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/effects.py +0 -0
  61. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/init.py +0 -0
  62. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/runtime.py +0 -0
  63. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/setup.py +0 -0
  64. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/stable.py +0 -0
  65. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/hooks/state.py +0 -0
  66. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/__init__.pyi +0 -0
  67. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/_types.py +0 -0
  68. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/array.py +0 -0
  69. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/console.py +0 -0
  70. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/date.py +0 -0
  71. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/document.py +0 -0
  72. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/error.py +0 -0
  73. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/json.py +0 -0
  74. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/map.py +0 -0
  75. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/math.py +0 -0
  76. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/navigator.py +0 -0
  77. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/number.py +0 -0
  78. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/obj.py +0 -0
  79. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/object.py +0 -0
  80. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/promise.py +0 -0
  81. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/pulse.py +0 -0
  82. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/regexp.py +0 -0
  83. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/set.py +0 -0
  84. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/string.py +0 -0
  85. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/weakmap.py +0 -0
  86. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/weakset.py +0 -0
  87. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/js/window.py +0 -0
  88. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/messages.py +0 -0
  89. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/middleware.py +0 -0
  90. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/plugin.py +0 -0
  91. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/proxy.py +0 -0
  92. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/py.typed +0 -0
  93. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/__init__.py +0 -0
  94. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/client.py +0 -0
  95. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/common.py +0 -0
  96. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/effect.py +0 -0
  97. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/infinite_query.py +0 -0
  98. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/mutation.py +0 -0
  99. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/protocol.py +0 -0
  100. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/query.py +0 -0
  101. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/queries/store.py +0 -0
  102. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/react_component.py +0 -0
  103. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/reactive.py +0 -0
  104. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/reactive_extensions.py +0 -0
  105. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/render_session.py +0 -0
  106. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/renderer.py +0 -0
  107. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/request.py +0 -0
  108. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/routing.py +0 -0
  109. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/serializer.py +0 -0
  110. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/state.py +0 -0
  111. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/builtins.py +0 -0
  112. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/errors.py +0 -0
  113. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/id.py +0 -0
  114. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/__init__.py +0 -0
  115. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/asyncio.py +0 -0
  116. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/json.py +0 -0
  117. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/math.py +0 -0
  118. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/pulse/__init__.py +0 -0
  119. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/pulse/tags.py +0 -0
  120. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/modules/typing.py +0 -0
  121. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/nodes.py +0 -0
  122. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/py_module.py +0 -0
  123. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/transpiler/vdom.py +0 -0
  124. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/types/__init__.py +0 -0
  125. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/types/event_handler.py +0 -0
  126. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/user_session.py +0 -0
  127. {pulse_framework-0.1.54 → pulse_framework-0.1.55}/src/pulse/version.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pulse-framework
3
- Version: 0.1.54
3
+ Version: 0.1.55
4
4
  Summary: Pulse - Full-stack framework for building real-time React applications in Python
5
5
  Requires-Dist: websockets>=12.0
6
6
  Requires-Dist: fastapi>=0.104.0
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pulse-framework"
3
- version = "0.1.54"
3
+ version = "0.1.55"
4
4
  description = "Pulse - Full-stack framework for building real-time React applications in Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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
@@ -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 get_registered_imports
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
- asset_import_paths = self._copy_local_files()
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) -> dict[str, str]:
161
- """Copy all registered local files to the assets directory.
156
+ def _copy_local_files(self) -> None:
157
+ """Copy all registered local assets to the assets directory.
162
158
 
163
- Collects all Import objects with is_local=True and copies their
164
- source files to the assets folder, returning an import path mapping.
159
+ Uses the unified asset registry which tracks local files from both
160
+ Import objects and DynamicImport expressions.
165
161
  """
166
- imports = get_registered_imports()
167
- local_imports = [imp for imp in imports if imp.is_local]
162
+ assets = get_registered_assets()
168
163
 
169
- if not local_imports:
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
- asset_filename = imp.asset_filename()
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 imp.source_path.exists():
184
- shutil.copy2(imp.source_path, dest_path)
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 {imp.source_path} -> {dest_path}")
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
- asset_filenames=asset_import_paths,
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
 
@@ -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
- if asset_filenames and src in asset_filenames:
57
- import_src = asset_prefix + asset_filenames[src]
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
- asset_filenames: dict[str, str] | None = None,
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
- asset_filenames: Mapping of original source paths to asset filenames
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
- # Note: Lazy component support is not yet implemented.
246
- # Components now register via the unified registry.
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
- # Add core Pulse imports
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
- # Generate output sections
258
- output_parts: list[str] = []
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
- imports_section = _generate_imports_section(
261
- all_imports, asset_filenames, asset_prefix
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
- output_parts.append("")
316
+ if funcs:
317
+ output_parts.append(_generate_functions_section(funcs))
318
+ output_parts.append("")
267
319
 
268
- if constants:
269
- output_parts.append(_generate_constants_section(constants))
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
- if funcs:
273
- output_parts.append(_generate_functions_section(funcs))
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
- output_parts.append("")
333
+ output_parts.append("")
290
334
 
291
- # Headers function
292
- output_parts.append("""// Action and loader headers are not returned automatically
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
- return "\n".join(output_parts)
345
+ return "\n".join(output_parts)
@@ -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
- fn: Callable[P, Any]
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.fn = rewrite_init_blocks(fn)
30
+ self._raw_fn = fn
29
31
  self.name = name or _infer_component_name(fn)
30
- self._takes_children = _takes_children(fn)
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
- if self._takes_children and args:
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.fn)!r})"
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:
@@ -39,7 +39,6 @@ _MODULE_EXPORTS_NAMESPACE: dict[str, str] = {
39
39
  "JSON": "pulse.js.json",
40
40
  "Math": "pulse.js.math",
41
41
  "React": "pulse.js.react",
42
- "ReactDOM": "pulse.js.react_dom",
43
42
  "console": "pulse.js.console",
44
43
  "window": "pulse.js.window",
45
44
  "document": "pulse.js.document",
@@ -54,6 +53,7 @@ _MODULE_EXPORTS_ATTRIBUTE: dict[str, str] = {
54
53
  "Map": "pulse.js.map",
55
54
  "Object": "pulse.js.object",
56
55
  "Promise": "pulse.js.promise",
56
+ "React": "pulse.js.react",
57
57
  "RegExp": "pulse.js.regexp",
58
58
  "Set": "pulse.js.set",
59
59
  "String": "pulse.js.string",
@@ -12,12 +12,27 @@ Usage:
12
12
  React.useState(0) # -> React.useState(0)
13
13
  """
14
14
 
15
+ import ast as _ast
15
16
  from collections.abc import Callable as _Callable
17
+ from typing import TYPE_CHECKING as _TYPE_CHECKING
16
18
  from typing import Any as _Any
17
19
  from typing import Protocol as _Protocol
18
20
  from typing import TypeVar as _TypeVar
21
+ from typing import override as _override
19
22
 
23
+ from pulse.component import component as _component
24
+ from pulse.transpiler import Import as _Import
25
+ from pulse.transpiler.errors import TranspileError as _TranspileError
26
+ from pulse.transpiler.function import Constant as _Constant
20
27
  from pulse.transpiler.js_module import JsModule
28
+ from pulse.transpiler.nodes import Call as _Call
29
+ from pulse.transpiler.nodes import Expr as _Expr
30
+ from pulse.transpiler.nodes import Jsx as _Jsx
31
+ from pulse.transpiler.nodes import Node as _PulseNode
32
+ from pulse.transpiler.vdom import VDOMNode as _VDOMNode
33
+
34
+ if _TYPE_CHECKING:
35
+ from pulse.transpiler.transpiler import Transpiler as _Transpiler
21
36
 
22
37
  # Type variables for hooks
23
38
  T = _TypeVar("T")
@@ -321,9 +336,99 @@ def forwardRef(
321
336
  ...
322
337
 
323
338
 
324
- def lazy(load: _Callable[[], _Any]) -> _Any:
325
- """Lets you defer loading a component's code until it is rendered."""
326
- ...
339
+ class _LazyComponentFactory(_Expr):
340
+ """React.lazy binding that works both at definition time and in @javascript.
341
+
342
+ This Expr represents React's lazy function. It can be:
343
+ - Called at Python definition time: lazy(factory) → Jsx(Constant(...))
344
+ - Used as a reference in @javascript: some_fn(lazy) → some_fn(lazy)
345
+ - Called inside @javascript: lazy(factory) → creates Constant+Jsx
346
+
347
+ Usage:
348
+ # At definition time (Python executes this)
349
+ LazyChart = lazy(Import("Chart", "./Chart", lazy=True))
350
+
351
+ # As reference in transpiled code
352
+ @javascript
353
+ def foo():
354
+ return higher_order_fn(lazy) # → higher_order_fn(lazy)
355
+
356
+ # Called in transpiled code
357
+ @javascript
358
+ def bar():
359
+ LazyComp = lazy(factory) # → const LazyComp_1 = lazy(factory)
360
+ return LazyComp()
361
+ """
362
+
363
+ __slots__: tuple[str, ...] = ("_lazy_import",)
364
+ _lazy_import: _Import | None
365
+
366
+ def __init__(self) -> None:
367
+ # Defer Import creation to avoid polluting global import registry at module load
368
+ self._lazy_import = None
369
+
370
+ @property
371
+ def _import(self) -> _Import:
372
+ """Lazily create the React.lazy import."""
373
+ if self._lazy_import is None:
374
+ self._lazy_import = _Import("lazy", "react")
375
+ return self._lazy_import
376
+
377
+ def _create_lazy_component(self, factory: _Expr) -> _Jsx:
378
+ """Create a lazy-loaded component from a factory expression.
379
+
380
+ Args:
381
+ factory: An Expr that evaluates to a dynamic import factory
382
+
383
+ Returns:
384
+ A Jsx-wrapped lazy component
385
+ """
386
+ lazy_call = _Call(self._import, [factory])
387
+ const = _Constant(lazy_call, lazy_call)
388
+ return _Jsx(const)
389
+
390
+ @_override
391
+ def emit(self, out: list[str]) -> None:
392
+ """Emit as reference to the lazy import."""
393
+ self._import.emit(out)
394
+
395
+ @_override
396
+ def render(self) -> _VDOMNode:
397
+ raise TypeError("lazy cannot be rendered to VDOM")
398
+
399
+ @_override
400
+ def transpile_call(
401
+ self,
402
+ args: list[_ast.expr],
403
+ keywords: list[_ast.keyword],
404
+ ctx: "_Transpiler",
405
+ ) -> _Expr:
406
+ """Handle lazy(factory) calls in @javascript functions."""
407
+ if keywords:
408
+ raise _TranspileError("lazy() does not accept keyword arguments")
409
+ if len(args) != 1:
410
+ raise _TranspileError("lazy() takes exactly 1 argument")
411
+
412
+ factory = ctx.emit_expr(args[0])
413
+ return self._create_lazy_component(factory)
414
+
415
+ @_override
416
+ def __call__(self, factory: _Import) -> _Jsx: # pyright: ignore[reportIncompatibleMethodOverride]
417
+ """Python-time call: create a lazy-loaded component.
418
+
419
+ Args:
420
+ factory: An Import with lazy=True that generates a dynamic import factory
421
+
422
+ Returns:
423
+ A Jsx-wrapped lazy component that can be used as LazyChart(props)[children]
424
+ """
425
+ return self._create_lazy_component(factory)
426
+
427
+
428
+ # Singleton instance - use as: lazy(Import(...))
429
+ lazy: _LazyComponentFactory = _LazyComponentFactory()
430
+ # Register so transpiler can resolve it from closure
431
+ _Expr.register(lazy, lazy)
327
432
 
328
433
 
329
434
  def createContext(default_value: T) -> Context[T]:
@@ -332,13 +437,15 @@ def createContext(default_value: T) -> Context[T]:
332
437
 
333
438
 
334
439
  # =============================================================================
335
- # Fragments
440
+ # Components (stub declarations become Jsx-wrapped imports)
336
441
  # =============================================================================
337
442
 
338
443
 
339
- class Fragment:
340
- """Lets you group elements without a wrapper node."""
341
-
444
+ @_component
445
+ def Suspense(
446
+ *, fallback: ReactNode | _PulseNode | None = None, name: str | None = None
447
+ ) -> ReactElement:
448
+ """Lets you display a fallback while its children are loading."""
342
449
  ...
343
450
 
344
451
 
@@ -3,10 +3,23 @@
3
3
  # Ensure built-in Python modules (e.g., math) are registered on import.
4
4
  from pulse.transpiler import modules as _modules # noqa: F401
5
5
 
6
+ # Asset registry (unified for Import and DynamicImport)
7
+ from pulse.transpiler.assets import LocalAsset as LocalAsset
8
+ from pulse.transpiler.assets import clear_asset_registry as clear_asset_registry
9
+ from pulse.transpiler.assets import get_registered_assets as get_registered_assets
10
+ from pulse.transpiler.assets import register_local_asset as register_local_asset
11
+
6
12
  # Builtins
7
13
  from pulse.transpiler.builtins import BUILTINS as BUILTINS
8
14
  from pulse.transpiler.builtins import emit_method as emit_method
9
15
 
16
+ # Dynamic import primitive
17
+ from pulse.transpiler.dynamic_import import DynamicImport as DynamicImport
18
+ from pulse.transpiler.dynamic_import import import_ as import_
19
+
20
+ # Emit context
21
+ from pulse.transpiler.emit_context import EmitContext as EmitContext
22
+
10
23
  # Errors
11
24
  from pulse.transpiler.errors import TranspileError as TranspileError
12
25