pulse-framework 0.1.0__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 +175 -0
- pulse/app.py +349 -0
- pulse/cmd.py +324 -0
- pulse/codegen.py +147 -0
- pulse/components/__init__.py +1 -0
- pulse/components/react_router.py +43 -0
- pulse/context.py +15 -0
- pulse/decorators.py +187 -0
- pulse/diff.py +252 -0
- pulse/flags.py +5 -0
- pulse/flatted.py +159 -0
- pulse/helpers.py +27 -0
- pulse/hooks.py +441 -0
- pulse/html/__init__.py +304 -0
- pulse/html/attributes.py +930 -0
- pulse/html/elements.py +1024 -0
- pulse/html/events.py +419 -0
- pulse/html/tags.py +171 -0
- pulse/html/tags.pyi +390 -0
- pulse/messages.py +109 -0
- pulse/middleware.py +158 -0
- pulse/query.py +286 -0
- pulse/react_component.py +803 -0
- pulse/reactive.py +514 -0
- pulse/reactive_extensions.py +626 -0
- pulse/reconciler.py +575 -0
- pulse/request.py +162 -0
- pulse/routing.py +350 -0
- pulse/session.py +310 -0
- pulse/state.py +309 -0
- pulse/templates.py +171 -0
- pulse/tests/__init__.py +0 -0
- pulse/tests/old_test_diff.py +174 -0
- pulse/tests/test_codegen.py +224 -0
- pulse/tests/test_flatted.py +297 -0
- pulse/tests/test_nodes.py +439 -0
- pulse/tests/test_query.py +391 -0
- pulse/tests/test_react.py +797 -0
- pulse/tests/test_reactive.py +1203 -0
- pulse/tests/test_reconciler.py +1759 -0
- pulse/tests/test_routing.py +167 -0
- pulse/tests/test_session.py +267 -0
- pulse/tests/test_state.py +569 -0
- pulse/tests/test_utils.py +101 -0
- pulse/vdom.py +381 -0
- pulse_framework-0.1.0.dist-info/METADATA +38 -0
- pulse_framework-0.1.0.dist-info/RECORD +50 -0
- pulse_framework-0.1.0.dist-info/WHEEL +4 -0
- pulse_framework-0.1.0.dist-info/entry_points.txt +2 -0
- pulse_framework-0.1.0.dist-info/licenses/LICENSE +21 -0
pulse/cmd.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Command-line interface for Pulse UI.
|
|
3
|
+
This module provides the CLI commands for running the server and generating routes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
import importlib.util
|
|
8
|
+
import os
|
|
9
|
+
import pty
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
from pulse.app import App
|
|
18
|
+
# from pulse.routing import clear_routes
|
|
19
|
+
|
|
20
|
+
from textual.app import App as TextualApp, ComposeResult
|
|
21
|
+
from textual.containers import Container
|
|
22
|
+
from textual.widgets import RichLog
|
|
23
|
+
from rich.text import Text
|
|
24
|
+
|
|
25
|
+
cli = typer.Typer(
|
|
26
|
+
name="pulse",
|
|
27
|
+
help="Pulse UI - Python to TypeScript bridge with server-side callbacks",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def find_available_port(start_port: int = 8000, max_attempts: int = 100) -> int:
|
|
33
|
+
"""Find an available port starting from start_port."""
|
|
34
|
+
for port in range(start_port, start_port + max_attempts):
|
|
35
|
+
try:
|
|
36
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
|
37
|
+
s.bind(("localhost", port))
|
|
38
|
+
return port
|
|
39
|
+
except OSError:
|
|
40
|
+
continue
|
|
41
|
+
raise RuntimeError(
|
|
42
|
+
f"Could not find an available port after {max_attempts} attempts starting from {start_port}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_app_from_file(file_path: str | Path) -> App:
|
|
47
|
+
"""Load routes from a Python file (supports both App instances and global @ps.route decorators)."""
|
|
48
|
+
file_path = Path(file_path)
|
|
49
|
+
|
|
50
|
+
if not file_path.exists():
|
|
51
|
+
typer.echo(f"❌ File not found: {file_path}")
|
|
52
|
+
raise typer.Exit(1)
|
|
53
|
+
|
|
54
|
+
if not file_path.suffix == ".py":
|
|
55
|
+
typer.echo(f"❌ File must be a Python file (.py): {file_path}")
|
|
56
|
+
raise typer.Exit(1)
|
|
57
|
+
|
|
58
|
+
# clear_routes()
|
|
59
|
+
sys.path.insert(0, str(file_path.parent.absolute()))
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
spec = importlib.util.spec_from_file_location("user_app", file_path)
|
|
63
|
+
if spec is None or spec.loader is None:
|
|
64
|
+
typer.echo(f"❌ Could not load module from: {file_path}")
|
|
65
|
+
raise typer.Exit(1)
|
|
66
|
+
|
|
67
|
+
module = importlib.util.module_from_spec(spec)
|
|
68
|
+
spec.loader.exec_module(module)
|
|
69
|
+
|
|
70
|
+
if hasattr(module, "app") and isinstance(module.app, App):
|
|
71
|
+
app_instance = module.app
|
|
72
|
+
if not app_instance.routes:
|
|
73
|
+
typer.echo(f"⚠️ No routes found in {file_path}")
|
|
74
|
+
return app_instance
|
|
75
|
+
|
|
76
|
+
typer.echo(f"⚠️ No app found in {file_path}")
|
|
77
|
+
raise typer.Exit(1)
|
|
78
|
+
|
|
79
|
+
except Exception as e:
|
|
80
|
+
typer.echo(f"❌ Error loading {file_path}: {e}")
|
|
81
|
+
raise typer.Exit(1)
|
|
82
|
+
finally:
|
|
83
|
+
if str(file_path.parent.absolute()) in sys.path:
|
|
84
|
+
sys.path.remove(str(file_path.parent.absolute()))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class Terminal(RichLog):
|
|
88
|
+
"""A widget that runs a command in a pseudo-terminal."""
|
|
89
|
+
|
|
90
|
+
def __init__(self, command, cwd, env=None, **kwargs):
|
|
91
|
+
super().__init__(highlight=True, markup=True, wrap=False, **kwargs)
|
|
92
|
+
self.command = command
|
|
93
|
+
self.cwd = cwd
|
|
94
|
+
self.env = env
|
|
95
|
+
self.pid = None
|
|
96
|
+
self.fd = None
|
|
97
|
+
|
|
98
|
+
async def on_mount(self) -> None:
|
|
99
|
+
"""Start the command when the widget is mounted."""
|
|
100
|
+
self.pid, self.fd = pty.fork()
|
|
101
|
+
|
|
102
|
+
if self.pid == 0: # Child process
|
|
103
|
+
os.chdir(self.cwd)
|
|
104
|
+
env = os.environ.copy()
|
|
105
|
+
if self.env:
|
|
106
|
+
env.update(self.env)
|
|
107
|
+
os.execvpe(self.command[0], self.command, env)
|
|
108
|
+
else: # Parent process
|
|
109
|
+
loop = asyncio.get_running_loop()
|
|
110
|
+
loop.add_reader(self.fd, self.read_from_pty)
|
|
111
|
+
|
|
112
|
+
def read_from_pty(self) -> None:
|
|
113
|
+
"""Read from the PTY and update the widget."""
|
|
114
|
+
if self.fd is None:
|
|
115
|
+
return
|
|
116
|
+
try:
|
|
117
|
+
data = os.read(self.fd, 1024)
|
|
118
|
+
if not data:
|
|
119
|
+
self.update_log_with_exit_message()
|
|
120
|
+
return
|
|
121
|
+
self.write(Text.from_ansi(data.decode(errors="replace")))
|
|
122
|
+
except OSError:
|
|
123
|
+
self.update_log_with_exit_message()
|
|
124
|
+
|
|
125
|
+
def update_log_with_exit_message(self):
|
|
126
|
+
if self.fd:
|
|
127
|
+
asyncio.get_running_loop().remove_reader(self.fd)
|
|
128
|
+
os.close(self.fd)
|
|
129
|
+
self.fd = None
|
|
130
|
+
self.write("\n\n[b red]PROCESS EXITED[/b red]")
|
|
131
|
+
self.border_style = "red"
|
|
132
|
+
|
|
133
|
+
async def on_key(self, event) -> None:
|
|
134
|
+
if self.fd:
|
|
135
|
+
if event.key == "ctrl+c":
|
|
136
|
+
os.write(self.fd, b"\x03")
|
|
137
|
+
else:
|
|
138
|
+
os.write(self.fd, event.key.encode())
|
|
139
|
+
|
|
140
|
+
def on_unmount(self) -> None:
|
|
141
|
+
"""Ensure the process is terminated on unmount."""
|
|
142
|
+
if self.pid:
|
|
143
|
+
try:
|
|
144
|
+
os.kill(self.pid, 9)
|
|
145
|
+
except ProcessLookupError:
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class PulseTerminalViewer(TextualApp):
|
|
150
|
+
"""A Textual app to view Pulse server logs in interactive terminals."""
|
|
151
|
+
|
|
152
|
+
CSS = """
|
|
153
|
+
Screen {
|
|
154
|
+
background: transparent;
|
|
155
|
+
}
|
|
156
|
+
#main_container {
|
|
157
|
+
layout: horizontal;
|
|
158
|
+
background: transparent;
|
|
159
|
+
}
|
|
160
|
+
Terminal {
|
|
161
|
+
width: 1fr;
|
|
162
|
+
height: 100%;
|
|
163
|
+
margin: 0 1;
|
|
164
|
+
scrollbar-size: 1 1;
|
|
165
|
+
}
|
|
166
|
+
Terminal:focus {
|
|
167
|
+
border: round white;
|
|
168
|
+
}
|
|
169
|
+
#server_term {
|
|
170
|
+
border: round cyan;
|
|
171
|
+
}
|
|
172
|
+
#web_term {
|
|
173
|
+
border: round orange;
|
|
174
|
+
}
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
BINDINGS = [("q", "quit", "Quit")]
|
|
178
|
+
|
|
179
|
+
def __init__(
|
|
180
|
+
self,
|
|
181
|
+
server_command=None,
|
|
182
|
+
server_cwd=None,
|
|
183
|
+
server_env=None,
|
|
184
|
+
web_command=None,
|
|
185
|
+
web_cwd=None,
|
|
186
|
+
web_env=None,
|
|
187
|
+
**kwargs,
|
|
188
|
+
):
|
|
189
|
+
super().__init__(**kwargs)
|
|
190
|
+
self.server_command = server_command
|
|
191
|
+
self.server_cwd = server_cwd
|
|
192
|
+
self.server_env = server_env
|
|
193
|
+
self.web_command = web_command
|
|
194
|
+
self.web_cwd = web_cwd
|
|
195
|
+
self.web_env = web_env
|
|
196
|
+
|
|
197
|
+
def compose(self) -> ComposeResult:
|
|
198
|
+
with Container(id="main_container"):
|
|
199
|
+
if self.server_command:
|
|
200
|
+
server_term = Terminal(
|
|
201
|
+
self.server_command,
|
|
202
|
+
self.server_cwd,
|
|
203
|
+
self.server_env,
|
|
204
|
+
id="server_term",
|
|
205
|
+
)
|
|
206
|
+
server_term.border_title = "🐍 Python Server"
|
|
207
|
+
yield server_term
|
|
208
|
+
|
|
209
|
+
if self.web_command:
|
|
210
|
+
web_term = Terminal(
|
|
211
|
+
self.web_command, self.web_cwd, self.web_env, id="web_term"
|
|
212
|
+
)
|
|
213
|
+
web_term.border_title = "🌐 Web Server"
|
|
214
|
+
yield web_term
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@cli.command("run")
|
|
218
|
+
def run(
|
|
219
|
+
app_file: str = typer.Argument(..., help="Python file with a pulse.App instance"),
|
|
220
|
+
address: str = typer.Option("localhost", "--address"),
|
|
221
|
+
port: int = typer.Option(8000, "--port"),
|
|
222
|
+
server_only: bool = typer.Option(False, "--server-only"),
|
|
223
|
+
web_only: bool = typer.Option(False, "--web-only"),
|
|
224
|
+
no_reload: bool = typer.Option(False, "--no-reload"),
|
|
225
|
+
find_port: bool = typer.Option(True, "--find-port/--no-find-port"),
|
|
226
|
+
):
|
|
227
|
+
"""Run the Pulse server and web development server together."""
|
|
228
|
+
if server_only and web_only:
|
|
229
|
+
typer.echo("❌ Cannot use --server-only and --web-only at the same time.")
|
|
230
|
+
raise typer.Exit(1)
|
|
231
|
+
|
|
232
|
+
if find_port:
|
|
233
|
+
port = find_available_port(port)
|
|
234
|
+
|
|
235
|
+
console = Console()
|
|
236
|
+
console.log(f"📁 Loading app from: {app_file}")
|
|
237
|
+
app_instance = load_app_from_file(app_file)
|
|
238
|
+
|
|
239
|
+
web_dir = Path(app_instance.codegen.cfg.web_dir)
|
|
240
|
+
if not web_dir.exists() and not server_only:
|
|
241
|
+
console.log(f"❌ Directory not found: {web_dir}")
|
|
242
|
+
raise typer.Exit(1)
|
|
243
|
+
|
|
244
|
+
server_command, server_cwd, server_env = None, None, None
|
|
245
|
+
web_command, web_cwd, web_env = None, None, None
|
|
246
|
+
|
|
247
|
+
if not web_only:
|
|
248
|
+
module_name = Path(app_file).stem
|
|
249
|
+
app_import_string = f"{module_name}:app.asgi_factory"
|
|
250
|
+
server_command = [
|
|
251
|
+
sys.executable,
|
|
252
|
+
"-m",
|
|
253
|
+
"uvicorn",
|
|
254
|
+
app_import_string,
|
|
255
|
+
"--host",
|
|
256
|
+
address,
|
|
257
|
+
"--port",
|
|
258
|
+
str(port),
|
|
259
|
+
"--factory",
|
|
260
|
+
]
|
|
261
|
+
if not no_reload:
|
|
262
|
+
server_command.append("--reload")
|
|
263
|
+
|
|
264
|
+
server_cwd = Path(app_file).parent
|
|
265
|
+
server_env = os.environ.copy()
|
|
266
|
+
server_env.update(
|
|
267
|
+
{
|
|
268
|
+
"PULSE_APP_FILE": app_file,
|
|
269
|
+
"PULSE_HOST": address,
|
|
270
|
+
"PULSE_PORT": str(port),
|
|
271
|
+
"PYTHONUNBUFFERED": "1",
|
|
272
|
+
"FORCE_COLOR": "1",
|
|
273
|
+
}
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
if not server_only:
|
|
277
|
+
web_command = ["bun", "run", "dev"]
|
|
278
|
+
web_cwd = web_dir
|
|
279
|
+
web_env = os.environ.copy()
|
|
280
|
+
web_env.update({"FORCE_COLOR": "1"})
|
|
281
|
+
|
|
282
|
+
app = PulseTerminalViewer(
|
|
283
|
+
server_command=server_command,
|
|
284
|
+
server_cwd=server_cwd,
|
|
285
|
+
server_env=server_env,
|
|
286
|
+
web_command=web_command,
|
|
287
|
+
web_cwd=web_cwd,
|
|
288
|
+
web_env=web_env,
|
|
289
|
+
)
|
|
290
|
+
app.run()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@cli.command("generate")
|
|
294
|
+
def generate(
|
|
295
|
+
app_file: str = typer.Argument(..., help="Path to your Python file with routes"),
|
|
296
|
+
):
|
|
297
|
+
"""Generate TypeScript routes without starting the server."""
|
|
298
|
+
console = Console()
|
|
299
|
+
console.log("🔄 Generating TypeScript routes...")
|
|
300
|
+
|
|
301
|
+
console.log(f"📁 Loading routes from: {app_file}")
|
|
302
|
+
app = load_app_from_file(app_file)
|
|
303
|
+
console.log(f"📋 Found {len(app.routes.flat_tree)} routes")
|
|
304
|
+
|
|
305
|
+
app.run_codegen("127.0.0.1:8000")
|
|
306
|
+
|
|
307
|
+
if len(app.routes.flat_tree) > 0:
|
|
308
|
+
console.log(f"✅ Generated {len(app.routes.flat_tree)} routes successfully!")
|
|
309
|
+
else:
|
|
310
|
+
console.log("⚠️ No routes found to generate")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def main():
|
|
314
|
+
"""Main CLI entry point."""
|
|
315
|
+
try:
|
|
316
|
+
cli()
|
|
317
|
+
except Exception as e:
|
|
318
|
+
console = Console()
|
|
319
|
+
console.log(f"❌ Error: {e}")
|
|
320
|
+
raise typer.Exit(1)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
main()
|
pulse/codegen.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from pulse.templates import LAYOUT_TEMPLATE, ROUTE_TEMPLATE, ROUTES_CONFIG_TEMPLATE
|
|
6
|
+
|
|
7
|
+
from .routing import Layout, Route, RouteTree
|
|
8
|
+
|
|
9
|
+
logger = logging.getLogger(__file__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class CodegenConfig:
|
|
14
|
+
"""
|
|
15
|
+
Configuration for code generation.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
web_dir (str): Root directory for the web output.
|
|
19
|
+
pulse_dir (str): Name of the Pulse app directory.
|
|
20
|
+
lib_path (str): Path to the Pulse library.
|
|
21
|
+
pulse_path (Path): Full path to the generated app directory.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
web_dir: Path | str = "pulse-web"
|
|
25
|
+
"""Root directory for the web output."""
|
|
26
|
+
|
|
27
|
+
pulse_dir: Path | str = "pulse"
|
|
28
|
+
"""Name of the Pulse app directory."""
|
|
29
|
+
|
|
30
|
+
lib_path: Path | str = "pulse-ui-client"
|
|
31
|
+
"""Path to the Pulse library."""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def pulse_path(self) -> Path:
|
|
35
|
+
"""Full path to the generated app directory."""
|
|
36
|
+
return Path(self.web_dir) / "app" / self.pulse_dir
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def write_file_if_changed(path: Path, content: str) -> Path:
|
|
40
|
+
"""Write content to file only if it has changed."""
|
|
41
|
+
if path.exists():
|
|
42
|
+
try:
|
|
43
|
+
current_content = path.read_text()
|
|
44
|
+
if current_content == content:
|
|
45
|
+
return path # Skip writing, content is the same
|
|
46
|
+
except Exception:
|
|
47
|
+
# If we can't read the file for any reason, just write it
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
51
|
+
path.write_text(content)
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class Codegen:
|
|
56
|
+
def __init__(self, routes: RouteTree, config: CodegenConfig) -> None:
|
|
57
|
+
self.cfg = config
|
|
58
|
+
self.routes = routes
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def output_folder(self):
|
|
62
|
+
return self.cfg.pulse_path
|
|
63
|
+
|
|
64
|
+
def generate_all(self, server_address: str):
|
|
65
|
+
# Keep track of all generated files
|
|
66
|
+
generated_files = [
|
|
67
|
+
self.generate_layout_tsx(server_address),
|
|
68
|
+
self.generate_routes_ts(),
|
|
69
|
+
*(
|
|
70
|
+
self.generate_route(route, server_address=server_address)
|
|
71
|
+
for route in self.routes.flat_tree.values()
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
generated_files = set(generated_files)
|
|
75
|
+
|
|
76
|
+
# Clean up any remaining files that are not part of the generated files
|
|
77
|
+
for path in self.output_folder.rglob("*"):
|
|
78
|
+
if path.is_file() and path not in generated_files:
|
|
79
|
+
try:
|
|
80
|
+
path.unlink()
|
|
81
|
+
logger.debug(f"Removed stale file: {path}")
|
|
82
|
+
except Exception as e:
|
|
83
|
+
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
84
|
+
|
|
85
|
+
def generate_layout_tsx(self, server_address: str):
|
|
86
|
+
"""Generates the content of _layout.tsx"""
|
|
87
|
+
content = str(
|
|
88
|
+
LAYOUT_TEMPLATE.render_unicode(
|
|
89
|
+
server_address=server_address, lib_path=self.cfg.lib_path
|
|
90
|
+
)
|
|
91
|
+
)
|
|
92
|
+
# The underscore avoids an eventual naming conflict with a generated
|
|
93
|
+
# /layout route.
|
|
94
|
+
return write_file_if_changed(self.output_folder / "_layout.tsx", content)
|
|
95
|
+
|
|
96
|
+
def generate_routes_ts(self):
|
|
97
|
+
"""Generate TypeScript code for the routes configuration."""
|
|
98
|
+
routes_str = self._render_routes_ts(self.routes.tree, 2)
|
|
99
|
+
content = str(
|
|
100
|
+
ROUTES_CONFIG_TEMPLATE.render_unicode(
|
|
101
|
+
routes_str=routes_str,
|
|
102
|
+
pulse_dir=self.cfg.pulse_dir,
|
|
103
|
+
)
|
|
104
|
+
)
|
|
105
|
+
return write_file_if_changed(self.output_folder / "routes.ts", content)
|
|
106
|
+
|
|
107
|
+
def _render_routes_ts(self, routes: list[Route | Layout], indent_level: int) -> str:
|
|
108
|
+
lines = []
|
|
109
|
+
indent_str = " " * indent_level
|
|
110
|
+
for route in routes:
|
|
111
|
+
if isinstance(route, Layout):
|
|
112
|
+
children_str = ""
|
|
113
|
+
if route.children:
|
|
114
|
+
children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
|
|
115
|
+
lines.append(
|
|
116
|
+
f'{indent_str}layout("{self.cfg.pulse_dir}/layouts/{route.file_path()}", [{children_str}]),'
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
if route.children:
|
|
120
|
+
children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
|
|
121
|
+
lines.append(
|
|
122
|
+
f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}", [{children_str}]),'
|
|
123
|
+
)
|
|
124
|
+
elif route.is_index:
|
|
125
|
+
lines.append(
|
|
126
|
+
f'{indent_str}index("{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
|
|
127
|
+
)
|
|
128
|
+
else:
|
|
129
|
+
lines.append(
|
|
130
|
+
f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
|
|
131
|
+
)
|
|
132
|
+
return "\n".join(lines)
|
|
133
|
+
|
|
134
|
+
def generate_route(self, route: Route | Layout, server_address: str):
|
|
135
|
+
if isinstance(route, Layout):
|
|
136
|
+
output_path = self.output_folder / "layouts" / route.file_path()
|
|
137
|
+
else:
|
|
138
|
+
output_path = self.output_folder / "routes" / route.file_path()
|
|
139
|
+
content = str(
|
|
140
|
+
ROUTE_TEMPLATE.render_unicode(
|
|
141
|
+
route=route,
|
|
142
|
+
components=route.components or [],
|
|
143
|
+
lib_path=self.cfg.lib_path,
|
|
144
|
+
server_address=server_address,
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
return write_file_if_changed(output_path, content)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .react_router import Link, Outlet
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
from typing import Literal, Optional, TypedDict, Unpack
|
|
2
|
+
from pulse.html.attributes import HTMLAnchorProps
|
|
3
|
+
from pulse.vdom import Child
|
|
4
|
+
from ..react_component import DEFAULT, react_component
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LinkPath(TypedDict):
|
|
8
|
+
pathname: str
|
|
9
|
+
search: str
|
|
10
|
+
hash: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@react_component("Link", "react-router")
|
|
14
|
+
def Link(
|
|
15
|
+
*children: Child,
|
|
16
|
+
key: Optional[str] = None,
|
|
17
|
+
to: str,
|
|
18
|
+
# Default: render
|
|
19
|
+
discover: Literal["render", "none"] = DEFAULT,
|
|
20
|
+
# The React Router default is 'none' to match the behavior of regular links,
|
|
21
|
+
# but 'intent' is more desirable in general
|
|
22
|
+
prefetch: Literal["none", "intent", "render", "viewport"] = "intent",
|
|
23
|
+
# Default: False
|
|
24
|
+
preventScrollReset: bool = DEFAULT,
|
|
25
|
+
# Default: 'route'
|
|
26
|
+
relative: Literal["route", "path"] = DEFAULT,
|
|
27
|
+
# Default: False
|
|
28
|
+
reloadDocument: bool = DEFAULT,
|
|
29
|
+
# Default: False
|
|
30
|
+
replace: bool = DEFAULT,
|
|
31
|
+
# Default: undefined
|
|
32
|
+
state: dict = DEFAULT,
|
|
33
|
+
# Default: False
|
|
34
|
+
viewTransition=DEFAULT,
|
|
35
|
+
**props: Unpack[HTMLAnchorProps],
|
|
36
|
+
): ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@react_component("Outlet", "react-router")
|
|
40
|
+
def Outlet(key: Optional[str] = None): ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
__all__ = ["Link", "Outlet"]
|
pulse/context.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# This is more for documentation than actually importing from here
|
|
2
|
+
from .reactive import REACTIVE_CONTEXT
|
|
3
|
+
from .routing import ROUTE_CONTEXT
|
|
4
|
+
# NOTE: SessionContext objecst set both the SESSION_CONTEXT and REACTIVE_CONTEXT
|
|
5
|
+
from .session import SESSION_CONTEXT
|
|
6
|
+
from .hooks import HOOK_CONTEXT
|
|
7
|
+
from .react_component import COMPONENT_REGISTRY
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"REACTIVE_CONTEXT",
|
|
11
|
+
"ROUTE_CONTEXT",
|
|
12
|
+
"SESSION_CONTEXT",
|
|
13
|
+
"HOOK_CONTEXT",
|
|
14
|
+
"COMPONENT_REGISTRY",
|
|
15
|
+
]
|