pulse-framework 0.1.51__py3-none-any.whl → 0.1.52__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.52.dist-info}/METADATA +1 -1
  70. pulse_framework-0.1.52.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.52.dist-info}/WHEEL +0 -0
  84. {pulse_framework-0.1.51.dist-info → pulse_framework-0.1.52.dist-info}/entry_points.txt +0 -0
pulse/cli/helpers.py CHANGED
@@ -1,15 +1,19 @@
1
+ from __future__ import annotations
2
+
1
3
  import importlib
2
4
  import importlib.util
3
5
  import platform
4
6
  import sys
5
7
  from pathlib import Path
6
- from typing import Literal, TypedDict
8
+ from typing import TYPE_CHECKING, Literal, TypedDict
7
9
 
8
10
  import typer
9
- from rich.console import Console
10
11
 
11
12
  from pulse.cli.models import AppLoadResult
12
13
 
14
+ if TYPE_CHECKING:
15
+ from pulse.cli.logging import CLILogger
16
+
13
17
 
14
18
  def os_family() -> Literal["windows", "mac", "linux"]:
15
19
  s = platform.system().lower()
@@ -110,66 +114,35 @@ def parse_app_target(target: str) -> ParsedAppTarget:
110
114
  }
111
115
 
112
116
 
113
- def load_app_from_file(file_path: str | Path) -> AppLoadResult:
114
- """Load routes from a Python file and return app context details."""
117
+ def load_app_from_target(target: str, logger: CLILogger | None = None) -> AppLoadResult:
118
+ """Load an App instance from either a file path (with optional :var) or a module path (uvicorn style).
119
+
120
+ Args:
121
+ target: The app target string (file path or module path)
122
+ logger: Optional CLILogger for error output. If not provided, uses basic print/traceback.
123
+ """
115
124
  # Avoid circular import
116
125
  from pulse.app import App
117
126
 
118
- file_path = Path(file_path)
119
-
120
- if not file_path.exists():
121
- typer.echo(f"❌ File not found: {file_path}")
122
- raise typer.Exit(1)
123
-
124
- if not file_path.suffix == ".py":
125
- typer.echo(f"❌ File must be a Python file (.py): {file_path}")
126
- raise typer.Exit(1)
127
-
128
- # clear_routes()
129
- sys.path.insert(0, str(file_path.parent.absolute()))
130
-
131
- try:
132
- spec = importlib.util.spec_from_file_location("user_app", file_path)
133
- if spec is None or spec.loader is None:
134
- typer.echo(f"❌ Could not load module from: {file_path}")
135
- raise typer.Exit(1)
136
-
137
- module = importlib.util.module_from_spec(spec)
138
- spec.loader.exec_module(module)
139
-
140
- if hasattr(module, "app") and isinstance(module.app, App):
141
- app_instance = module.app
142
- if not app_instance.routes:
143
- typer.echo(f"⚠️ No routes found in {file_path}")
144
- return AppLoadResult(
145
- target=str(file_path),
146
- mode="path",
147
- app=app_instance,
148
- module_name="user_app",
149
- app_var="app",
150
- app_file=file_path.resolve(),
151
- app_dir=file_path.parent.resolve(),
152
- server_cwd=file_path.parent.resolve(),
153
- )
154
-
155
- typer.echo(f"⚠️ No app found in {file_path}")
156
- raise typer.Exit(1)
157
-
158
- except Exception:
159
- console = Console()
160
- console.log(f"❌ Error loading {file_path}")
161
- console.print_exception()
162
- raise typer.Exit(1) from None
163
- finally:
164
- if str(file_path.parent.absolute()) in sys.path:
165
- sys.path.remove(str(file_path.parent.absolute()))
127
+ def _log_error(message: str) -> None:
128
+ if logger:
129
+ logger.error(message)
130
+ else:
131
+ print(f"Error: {message}")
166
132
 
133
+ def _log_warning(message: str) -> None:
134
+ if logger:
135
+ logger.warning(message)
136
+ else:
137
+ print(f"Warning: {message}")
167
138
 
168
- def load_app_from_target(target: str) -> AppLoadResult:
169
- """Load an App instance from either a file path (with optional :var) or a module path (uvicorn style)."""
139
+ def _print_exception() -> None:
140
+ if logger:
141
+ logger.print_exception()
142
+ else:
143
+ import traceback
170
144
 
171
- # Avoid circulart import
172
- from pulse.app import App
145
+ traceback.print_exc()
173
146
 
174
147
  parsed = parse_app_target(target)
175
148
 
@@ -181,22 +154,23 @@ def load_app_from_target(target: str) -> AppLoadResult:
181
154
  if parsed["mode"] == "path":
182
155
  file_path = parsed["file_path"]
183
156
  if file_path is None:
184
- typer.echo(f"Could not determine a Python file from: {target}")
157
+ _log_error(f"Could not determine a Python file from: {target}")
185
158
  raise typer.Exit(1)
186
159
 
187
160
  sys.path.insert(0, str(file_path.parent.absolute()))
188
161
  try:
189
162
  spec = importlib.util.spec_from_file_location(module_name, file_path)
190
163
  if spec is None or spec.loader is None:
191
- typer.echo(f"Could not load module from: {file_path}")
164
+ _log_error(f"Could not load module from: {file_path}")
192
165
  raise typer.Exit(1)
193
166
  module = importlib.util.module_from_spec(spec)
194
167
  sys.modules[spec.name] = module
195
168
  spec.loader.exec_module(module)
169
+ except typer.Exit:
170
+ raise
196
171
  except Exception:
197
- console = Console()
198
- console.log(f"❌ Error loading {file_path}")
199
- console.print_exception()
172
+ _log_error(f"Error loading {file_path}")
173
+ _print_exception()
200
174
  raise typer.Exit(1) from None
201
175
  finally:
202
176
  if str(file_path.parent.absolute()) in sys.path:
@@ -210,9 +184,8 @@ def load_app_from_target(target: str) -> AppLoadResult:
210
184
  try:
211
185
  module = importlib.import_module(module_name) # type: ignore[name-defined]
212
186
  except Exception:
213
- console = Console()
214
- console.log(f"❌ Error importing module: {module_name}")
215
- console.print_exception()
187
+ _log_error(f"Error importing module: {module_name}")
188
+ _print_exception()
216
189
  raise typer.Exit(1) from None
217
190
 
218
191
  # Try to set env paths from the resolved module file
@@ -225,14 +198,14 @@ def load_app_from_target(target: str) -> AppLoadResult:
225
198
 
226
199
  # Fetch the app attribute
227
200
  if not hasattr(loaded_module, app_var):
228
- typer.echo(f"App variable '{app_var}' not found in {module_name}")
201
+ _log_error(f"App variable '{app_var}' not found in {module_name}")
229
202
  raise typer.Exit(1)
230
203
  app_candidate = getattr(loaded_module, app_var)
231
204
  if not isinstance(app_candidate, App):
232
- typer.echo(f"'{app_var}' in {module_name} is not a pulse.App instance")
205
+ _log_error(f"'{app_var}' in {module_name} is not a pulse.App instance")
233
206
  raise typer.Exit(1)
234
207
  if not app_candidate.routes:
235
- typer.echo("⚠️ No routes found")
208
+ _log_warning("No routes found")
236
209
  return AppLoadResult(
237
210
  target=target,
238
211
  mode=parsed["mode"],
pulse/cli/logging.py ADDED
@@ -0,0 +1,102 @@
1
+ """
2
+ Mode-aware CLI logging for Pulse.
3
+
4
+ In dev mode, uses Rich Console with colors.
5
+ In ci/prod mode or with --plain, uses plain print().
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ from pulse.env import PulseEnv
14
+
15
+ if TYPE_CHECKING:
16
+ from rich.console import Console
17
+
18
+ TagMode = Literal["colored", "plain"]
19
+
20
+
21
+ class CLILogger:
22
+ """Mode-aware CLI logger that adapts output based on pulse environment.
23
+
24
+ Args:
25
+ mode: The pulse environment mode (dev, ci, prod)
26
+ plain: Force plain output without colors, even in dev mode
27
+ """
28
+
29
+ mode: PulseEnv
30
+ plain: bool
31
+ _console: Console | None
32
+
33
+ def __init__(self, mode: PulseEnv = "dev", *, plain: bool = False):
34
+ self.mode = mode
35
+ self.plain = plain
36
+ self._console = None
37
+ if mode == "dev" and not plain:
38
+ from rich.console import Console
39
+
40
+ self._console = Console()
41
+
42
+ @property
43
+ def is_plain(self) -> bool:
44
+ """Return True if using plain output (ci/prod mode or --plain flag)."""
45
+ return self.mode != "dev" or self.plain
46
+
47
+ def print(self, message: str) -> None:
48
+ """Print a message."""
49
+ if self._console:
50
+ self._console.print(message)
51
+ else:
52
+ print(message)
53
+
54
+ def error(self, message: str) -> None:
55
+ """Print an error message."""
56
+ if self._console:
57
+ self._console.print(f"[red]Error:[/red] {message}")
58
+ else:
59
+ print(f"Error: {message}")
60
+
61
+ def success(self, message: str) -> None:
62
+ """Print a success message."""
63
+ if self._console:
64
+ self._console.print(f"[green]✓[/green] {message}")
65
+ else:
66
+ print(f"Done: {message}")
67
+
68
+ def warning(self, message: str) -> None:
69
+ """Print a warning message."""
70
+ if self._console:
71
+ self._console.print(f"[yellow]Warning:[/yellow] {message}")
72
+ else:
73
+ print(f"Warning: {message}")
74
+
75
+ def print_exception(self) -> None:
76
+ """Print the current exception."""
77
+ if self._console:
78
+ self._console.print_exception()
79
+ else:
80
+ import traceback
81
+
82
+ traceback.print_exc()
83
+
84
+ def get_tag_mode(self) -> TagMode:
85
+ """Return tag mode for process output: colored in dev, plain in ci/prod."""
86
+ return "plain" if self.is_plain else "colored"
87
+
88
+ def write_ready_announcement(
89
+ self, address: str, port: int, server_url: str
90
+ ) -> None:
91
+ """Write the 'Pulse is ready' announcement."""
92
+ if self._console:
93
+ self._console.print("")
94
+ self._console.print(
95
+ f"[bold green]Ready:[/bold green] [bold cyan][link={server_url}]{server_url}[/link][/bold cyan]"
96
+ )
97
+ self._console.print("")
98
+ else:
99
+ print("")
100
+ print(f"Ready: {server_url}")
101
+ print("")
102
+ sys.stdout.flush()
pulse/cli/packages.py CHANGED
@@ -116,6 +116,22 @@ def pick_more_specific(a: str | None, b: str | None) -> str | None:
116
116
  return a
117
117
  if b_exact:
118
118
  return b
119
+
120
+ # If both are ranges, prefer higher version if possible (heuristic)
121
+ if a.startswith(("^", "~")) and b.startswith(("^", "~")):
122
+ av = a[1:]
123
+ bv = b[1:]
124
+ # Basic version comparison for digits
125
+ try:
126
+ a_parts = [int(p) for p in av.split(".") if p.isdigit()]
127
+ b_parts = [int(p) for p in bv.split(".") if p.isdigit()]
128
+ if a_parts > b_parts:
129
+ return a
130
+ if b_parts > a_parts:
131
+ return b
132
+ except ValueError:
133
+ pass
134
+
119
135
  # Prefer longer constraint as proxy for specificity
120
136
  return a if len(a) >= len(b) else b
121
137
 
pulse/cli/processes.py CHANGED
@@ -8,23 +8,26 @@ import select
8
8
  import signal
9
9
  import subprocess
10
10
  import sys
11
- from collections.abc import Mapping, Sequence
11
+ from collections.abc import Sequence
12
12
  from io import TextIOBase
13
13
  from typing import TypeVar, cast
14
14
 
15
- from rich.console import Console
16
-
17
15
  from pulse.cli.helpers import os_family
16
+ from pulse.cli.logging import TagMode
18
17
  from pulse.cli.models import CommandSpec
19
18
 
20
19
  _K = TypeVar("_K", int, str)
21
20
 
21
+ # ANSI color codes for tagged output
22
22
  ANSI_CODES = {
23
23
  "cyan": "\033[36m",
24
24
  "orange1": "\033[38;5;208m",
25
- "default": "\033[90m",
25
+ "reset": "\033[0m",
26
26
  }
27
27
 
28
+ # Tag colors mapping (used only in colored mode)
29
+ TAG_COLORS = {"server": "cyan", "web": "orange1"}
30
+
28
31
  # Regex to strip ANSI escape codes
29
32
  ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
30
33
 
@@ -32,23 +35,27 @@ ANSI_ESCAPE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
32
35
  def execute_commands(
33
36
  commands: Sequence[CommandSpec],
34
37
  *,
35
- console: Console,
36
- tag_colors: Mapping[str, str] | None = None,
38
+ tag_mode: TagMode = "colored",
37
39
  ) -> int:
38
- """Run the provided commands, streaming tagged output to stdout."""
40
+ """Run the provided commands, streaming tagged output to stdout.
41
+
42
+ Args:
43
+ commands: List of command specifications to run
44
+ tag_mode: How to display process tags:
45
+ - "colored": Show [server]/[web] with ANSI colors (dev mode)
46
+ - "plain": Show [server]/[web] without colors (ci/prod mode)
47
+ """
39
48
  if not commands:
40
49
  return 0
41
50
 
42
- color_lookup = dict(tag_colors or {})
43
-
44
51
  # Avoid pty.fork() in multi-threaded environments (like pytest) to prevent
45
52
  # "DeprecationWarning: This process is multi-threaded, use of forkpty() may lead to deadlocks"
46
53
  # Also skip pty on Windows or if fork is unavailable
47
54
  in_pytest = "pytest" in sys.modules
48
55
  if os_family() == "windows" or not hasattr(pty, "fork") or in_pytest:
49
- return _run_without_pty(commands, console=console, colors=color_lookup)
56
+ return _run_without_pty(commands, tag_mode=tag_mode)
50
57
 
51
- return _run_with_pty(commands, console=console, colors=color_lookup)
58
+ return _run_with_pty(commands, tag_mode=tag_mode)
52
59
 
53
60
 
54
61
  def _call_on_spawn(spec: CommandSpec) -> None:
@@ -82,8 +89,7 @@ def _check_on_ready(
82
89
  def _run_with_pty(
83
90
  commands: Sequence[CommandSpec],
84
91
  *,
85
- console: Console,
86
- colors: Mapping[str, str],
92
+ tag_mode: TagMode,
87
93
  ) -> int:
88
94
  procs: list[tuple[str, int, int]] = []
89
95
  fd_to_spec: dict[int, CommandSpec] = {}
@@ -138,7 +144,7 @@ def _run_with_pty(
138
144
  decoded = line.decode(errors="replace")
139
145
  if decoded:
140
146
  spec = fd_to_spec[fd]
141
- _write_tagged_line(spec.name, decoded, colors)
147
+ _write_tagged_line(spec.name, decoded, tag_mode)
142
148
  _check_on_ready(spec, decoded, ready_flags, fd)
143
149
  except OSError:
144
150
  continue
@@ -173,8 +179,7 @@ def _run_with_pty(
173
179
  def _run_without_pty(
174
180
  commands: Sequence[CommandSpec],
175
181
  *,
176
- console: Console,
177
- colors: Mapping[str, str],
182
+ tag_mode: TagMode,
178
183
  ) -> int:
179
184
  from selectors import EVENT_READ, DefaultSelector
180
185
 
@@ -211,7 +216,7 @@ def _run_without_pty(
211
216
  # stream is now guaranteed to be a file-like object
212
217
  line = cast(TextIOBase, stream).readline()
213
218
  if line:
214
- _write_tagged_line(name, line.rstrip("\n"), colors)
219
+ _write_tagged_line(name, line.rstrip("\n"), tag_mode)
215
220
  spec = next((s for n, _, s in procs if n == name), None)
216
221
  if spec:
217
222
  _check_on_ready(spec, line, ready_flags, name)
@@ -251,7 +256,16 @@ def _run_without_pty(
251
256
  return max(exit_codes) if exit_codes else 0
252
257
 
253
258
 
254
- def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> None:
259
+ def _write_tagged_line(name: str, message: str, tag_mode: TagMode) -> None:
260
+ """Write a line of output with optional process tag.
261
+
262
+ Args:
263
+ name: Process name (e.g., "server", "web")
264
+ message: The line of output to write
265
+ tag_mode: How to display the tag:
266
+ - "colored": Show [name] with ANSI colors
267
+ - "plain": Show [name] without colors
268
+ """
255
269
  # Filter out unwanted web server messages
256
270
  clean_message = ANSI_ESCAPE.sub("", message)
257
271
  if (
@@ -261,12 +275,15 @@ def _write_tagged_line(name: str, message: str, colors: Mapping[str, str]) -> No
261
275
  ):
262
276
  return
263
277
 
264
- # Only add tags if colors dict is not empty (i.e., tagging is enabled)
265
- if colors:
266
- color = ANSI_CODES.get(colors.get(name, ""), ANSI_CODES["default"])
267
- sys.stdout.write(f"{color}[{name}]\033[0m {message}\n")
278
+ if tag_mode == "colored":
279
+ color = ANSI_CODES.get(TAG_COLORS.get(name, ""), "")
280
+ if color:
281
+ sys.stdout.write(f"{color}[{name}]{ANSI_CODES['reset']} {message}\n")
282
+ else:
283
+ sys.stdout.write(f"[{name}] {message}\n")
268
284
  else:
269
- sys.stdout.write(f"{message}\n")
285
+ # Plain mode: tags without color
286
+ sys.stdout.write(f"[{name}] {message}\n")
270
287
  sys.stdout.flush()
271
288
 
272
289
 
pulse/codegen/codegen.py CHANGED
@@ -1,4 +1,5 @@
1
1
  import logging
2
+ import shutil
2
3
  from collections.abc import Sequence
3
4
  from dataclasses import dataclass
4
5
  from pathlib import Path
@@ -13,7 +14,7 @@ from pulse.codegen.templates.routes_ts import (
13
14
  )
14
15
  from pulse.env import env
15
16
  from pulse.routing import Layout, Route, RouteTree
16
- from pulse.transpiler.imports import registered_imports
17
+ from pulse.transpiler import get_registered_imports
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  from pulse.app import ConnectionStatusConfig
@@ -99,14 +100,16 @@ class Codegen:
99
100
  def __init__(self, routes: RouteTree, config: CodegenConfig) -> None:
100
101
  self.cfg = config
101
102
  self.routes = routes
102
- self._copied_css_files: set[Path] = set()
103
- # Maps source path -> destination path for CSS files
104
- self._css_dest_paths: dict[str, Path] = {}
103
+ self._copied_files: set[Path] = set()
105
104
 
106
105
  @property
107
106
  def output_folder(self):
108
107
  return self.cfg.pulse_path
109
108
 
109
+ @property
110
+ def assets_folder(self):
111
+ return self.output_folder / "assets"
112
+
110
113
  def generate_all(
111
114
  self,
112
115
  server_address: str,
@@ -117,11 +120,10 @@ class Codegen:
117
120
  # Ensure generated files are gitignored
118
121
  ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
119
122
 
120
- self._copied_css_files = set()
121
- self._css_dest_paths = {}
123
+ self._copied_files = set()
122
124
 
123
- # Copy all registered CSS files to the output css directory
124
- self._copy_css_files()
125
+ # Copy all registered local files to the assets directory
126
+ asset_import_paths = self._copy_local_files()
125
127
 
126
128
  # Keep track of all generated files
127
129
  generated_files = set(
@@ -135,12 +137,16 @@ class Codegen:
135
137
  self.generate_routes_ts(),
136
138
  self.generate_routes_runtime_ts(),
137
139
  *(
138
- self.generate_route(route, server_address=server_address)
140
+ self.generate_route(
141
+ route,
142
+ server_address=server_address,
143
+ asset_import_paths=asset_import_paths,
144
+ )
139
145
  for route in self.routes.flat_tree.values()
140
146
  ),
141
147
  ]
142
148
  )
143
- generated_files.update(self._copied_css_files)
149
+ generated_files.update(self._copied_files)
144
150
 
145
151
  # Clean up any remaining files that are not part of the generated files
146
152
  for path in self.output_folder.rglob("*"):
@@ -151,31 +157,52 @@ class Codegen:
151
157
  except Exception as e:
152
158
  logger.warning(f"Could not remove stale file {path}: {e}")
153
159
 
154
- def _copy_css_files(self) -> None:
155
- """Copy all registered local CSS files to the output css directory."""
156
- from pulse.transpiler.imports import CssImport
160
+ def _copy_local_files(self) -> dict[str, str]:
161
+ """Copy all registered local files to the assets directory.
162
+
163
+ Collects all Import objects with is_local=True and copies their
164
+ source files to the assets folder, returning an import path mapping.
165
+ """
166
+ imports = get_registered_imports()
167
+ local_imports = [imp for imp in imports if imp.is_local]
168
+
169
+ if not local_imports:
170
+ return {}
157
171
 
158
- css_dir = self.output_folder / "css"
172
+ self.assets_folder.mkdir(parents=True, exist_ok=True)
173
+ asset_import_paths: dict[str, str] = {}
159
174
 
160
- for imp in registered_imports():
161
- if not isinstance(imp, CssImport) or not imp.is_local:
175
+ for imp in local_imports:
176
+ if imp.source_path is None:
162
177
  continue
163
178
 
164
- # Local CssImport has source_path and generated_filename set
165
- source_path = imp.source_path
166
- generated_filename = imp.generated_filename
167
- assert source_path is not None and generated_filename is not None
179
+ asset_filename = imp.asset_filename()
180
+ dest_path = self.assets_folder / asset_filename
168
181
 
169
- if not source_path.exists():
170
- logger.warning(f"CSS file not found: {source_path}")
171
- continue
182
+ # Copy file if source exists
183
+ if imp.source_path.exists():
184
+ shutil.copy2(imp.source_path, dest_path)
185
+ 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
172
190
 
173
- dest_path = css_dir / generated_filename
174
- dest_path.parent.mkdir(parents=True, exist_ok=True)
175
- content = source_path.read_text()
176
- write_file_if_changed(dest_path, content)
177
- self._copied_css_files.add(dest_path)
178
- self._css_dest_paths[str(source_path)] = dest_path
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/"
179
206
 
180
207
  def generate_layout_tsx(
181
208
  self,
@@ -250,17 +277,25 @@ class Codegen:
250
277
  )
251
278
  return "\n".join(lines)
252
279
 
253
- def generate_route(self, route: Route | Layout, server_address: str):
280
+ def generate_route(
281
+ self,
282
+ route: Route | Layout,
283
+ server_address: str,
284
+ asset_import_paths: dict[str, str],
285
+ ):
286
+ route_file_path = route.file_path()
254
287
  if isinstance(route, Layout):
255
- output_path = self.output_folder / "layouts" / route.file_path()
288
+ output_path = self.output_folder / "layouts" / route_file_path
256
289
  else:
257
- output_path = self.output_folder / "routes" / route.file_path()
290
+ 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)
258
294
 
259
295
  content = generate_route(
260
296
  path=route.unique_path(),
261
- components=list(route.components) if route.components else None,
262
- route_file_path=output_path,
263
- css_dir=self.output_folder / "css",
297
+ asset_filenames=asset_import_paths,
298
+ asset_prefix=asset_prefix,
264
299
  )
265
300
  return write_file_if_changed(output_path, content)
266
301
 
pulse/codegen/js.py CHANGED
@@ -43,10 +43,8 @@ class ExternalJsFunction(Generic[*Args, R]):
43
43
  is_default: bool,
44
44
  hint: Callable[[*Args], R],
45
45
  ) -> None:
46
- if is_default:
47
- self.import_ = Import.default(name, src)
48
- else:
49
- self.import_ = Import.named(name, src)
46
+ kind = "default" if is_default else "named"
47
+ self.import_ = Import(name, src, kind=kind)
50
48
  self._prop = prop
51
49
  self.hint = hint
52
50