pulse-framework 0.1.62__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 +1493 -0
- pulse/_examples.py +29 -0
- pulse/app.py +1086 -0
- pulse/channel.py +607 -0
- pulse/cli/__init__.py +0 -0
- pulse/cli/cmd.py +575 -0
- pulse/cli/dependencies.py +181 -0
- pulse/cli/folder_lock.py +134 -0
- pulse/cli/helpers.py +271 -0
- pulse/cli/logging.py +102 -0
- pulse/cli/models.py +35 -0
- pulse/cli/packages.py +262 -0
- pulse/cli/processes.py +292 -0
- pulse/cli/secrets.py +39 -0
- pulse/cli/uvicorn_log_config.py +87 -0
- pulse/code_analysis.py +38 -0
- pulse/codegen/__init__.py +0 -0
- pulse/codegen/codegen.py +359 -0
- pulse/codegen/templates/__init__.py +0 -0
- pulse/codegen/templates/layout.py +106 -0
- pulse/codegen/templates/route.py +345 -0
- pulse/codegen/templates/routes_ts.py +42 -0
- pulse/codegen/utils.py +20 -0
- pulse/component.py +237 -0
- pulse/components/__init__.py +0 -0
- pulse/components/for_.py +83 -0
- pulse/components/if_.py +86 -0
- pulse/components/react_router.py +94 -0
- pulse/context.py +108 -0
- pulse/cookies.py +322 -0
- pulse/decorators.py +344 -0
- pulse/dom/__init__.py +0 -0
- pulse/dom/elements.py +1024 -0
- pulse/dom/events.py +445 -0
- pulse/dom/props.py +1250 -0
- pulse/dom/svg.py +0 -0
- pulse/dom/tags.py +328 -0
- pulse/dom/tags.pyi +480 -0
- pulse/env.py +178 -0
- pulse/form.py +538 -0
- pulse/helpers.py +541 -0
- pulse/hooks/__init__.py +0 -0
- pulse/hooks/core.py +452 -0
- pulse/hooks/effects.py +88 -0
- pulse/hooks/init.py +668 -0
- pulse/hooks/runtime.py +464 -0
- pulse/hooks/setup.py +254 -0
- pulse/hooks/stable.py +138 -0
- pulse/hooks/state.py +192 -0
- pulse/js/__init__.py +125 -0
- pulse/js/__init__.pyi +115 -0
- pulse/js/_types.py +299 -0
- pulse/js/array.py +339 -0
- pulse/js/console.py +50 -0
- pulse/js/date.py +119 -0
- pulse/js/document.py +145 -0
- pulse/js/error.py +140 -0
- pulse/js/json.py +66 -0
- pulse/js/map.py +97 -0
- pulse/js/math.py +69 -0
- pulse/js/navigator.py +79 -0
- pulse/js/number.py +57 -0
- pulse/js/obj.py +81 -0
- pulse/js/object.py +172 -0
- pulse/js/promise.py +172 -0
- pulse/js/pulse.py +115 -0
- pulse/js/react.py +495 -0
- pulse/js/regexp.py +57 -0
- pulse/js/set.py +124 -0
- pulse/js/string.py +38 -0
- pulse/js/weakmap.py +53 -0
- pulse/js/weakset.py +48 -0
- pulse/js/window.py +205 -0
- pulse/messages.py +202 -0
- pulse/middleware.py +471 -0
- pulse/plugin.py +96 -0
- pulse/proxy.py +242 -0
- pulse/py.typed +0 -0
- pulse/queries/__init__.py +0 -0
- pulse/queries/client.py +609 -0
- pulse/queries/common.py +101 -0
- pulse/queries/effect.py +55 -0
- pulse/queries/infinite_query.py +1418 -0
- pulse/queries/mutation.py +295 -0
- pulse/queries/protocol.py +136 -0
- pulse/queries/query.py +1314 -0
- pulse/queries/store.py +120 -0
- pulse/react_component.py +88 -0
- pulse/reactive.py +1208 -0
- pulse/reactive_extensions.py +1172 -0
- pulse/render_session.py +768 -0
- pulse/renderer.py +584 -0
- pulse/request.py +205 -0
- pulse/routing.py +598 -0
- pulse/serializer.py +279 -0
- pulse/state.py +556 -0
- pulse/test_helpers.py +15 -0
- pulse/transpiler/__init__.py +111 -0
- pulse/transpiler/assets.py +81 -0
- pulse/transpiler/builtins.py +1029 -0
- pulse/transpiler/dynamic_import.py +130 -0
- pulse/transpiler/emit_context.py +49 -0
- pulse/transpiler/errors.py +96 -0
- pulse/transpiler/function.py +611 -0
- pulse/transpiler/id.py +18 -0
- pulse/transpiler/imports.py +341 -0
- pulse/transpiler/js_module.py +336 -0
- pulse/transpiler/modules/__init__.py +33 -0
- pulse/transpiler/modules/asyncio.py +57 -0
- pulse/transpiler/modules/json.py +24 -0
- pulse/transpiler/modules/math.py +265 -0
- pulse/transpiler/modules/pulse/__init__.py +5 -0
- pulse/transpiler/modules/pulse/tags.py +250 -0
- pulse/transpiler/modules/typing.py +63 -0
- pulse/transpiler/nodes.py +1987 -0
- pulse/transpiler/py_module.py +135 -0
- pulse/transpiler/transpiler.py +1100 -0
- pulse/transpiler/vdom.py +256 -0
- pulse/types/__init__.py +0 -0
- pulse/types/event_handler.py +50 -0
- pulse/user_session.py +386 -0
- pulse/version.py +69 -0
- pulse_framework-0.1.62.dist-info/METADATA +198 -0
- pulse_framework-0.1.62.dist-info/RECORD +126 -0
- pulse_framework-0.1.62.dist-info/WHEEL +4 -0
- pulse_framework-0.1.62.dist-info/entry_points.txt +3 -0
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
|
|
File without changes
|
pulse/codegen/codegen.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Sequence
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from pulse.cli.helpers import ensure_gitignore_has
|
|
8
|
+
from pulse.codegen.templates.layout import LAYOUT_TEMPLATE
|
|
9
|
+
from pulse.codegen.templates.route import generate_route
|
|
10
|
+
from pulse.codegen.templates.routes_ts import (
|
|
11
|
+
ROUTES_CONFIG_TEMPLATE,
|
|
12
|
+
ROUTES_RUNTIME_TEMPLATE,
|
|
13
|
+
)
|
|
14
|
+
from pulse.env import env
|
|
15
|
+
from pulse.routing import Layout, Route, RouteTree
|
|
16
|
+
from pulse.transpiler.assets import get_registered_assets
|
|
17
|
+
|
|
18
|
+
if TYPE_CHECKING:
|
|
19
|
+
from pulse.app import ConnectionStatusConfig
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__file__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class CodegenConfig:
|
|
26
|
+
"""Configuration for code generation output paths.
|
|
27
|
+
|
|
28
|
+
Controls where generated React Router files are written. All paths
|
|
29
|
+
can be relative (resolved against base_dir) or absolute.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
web_dir: Root directory for web output. Defaults to "web".
|
|
33
|
+
pulse_dir: Subdirectory for generated Pulse files. Defaults to "pulse".
|
|
34
|
+
base_dir: Base directory for resolving relative paths. If not provided,
|
|
35
|
+
resolved from PULSE_APP_FILE, PULSE_APP_DIR, or cwd.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
web_dir: Root directory for web output.
|
|
39
|
+
pulse_dir: Subdirectory name for generated files.
|
|
40
|
+
base_dir: Explicit base directory, if provided.
|
|
41
|
+
|
|
42
|
+
Example:
|
|
43
|
+
```python
|
|
44
|
+
app = ps.App(
|
|
45
|
+
codegen=ps.CodegenConfig(
|
|
46
|
+
web_dir="frontend",
|
|
47
|
+
pulse_dir="generated",
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
# Generated files will be at: frontend/app/generated/
|
|
51
|
+
```
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
web_dir: Path | str = "web"
|
|
55
|
+
"""Root directory for the web output."""
|
|
56
|
+
|
|
57
|
+
pulse_dir: Path | str = "pulse"
|
|
58
|
+
"""Name of the Pulse app directory."""
|
|
59
|
+
|
|
60
|
+
base_dir: Path | None = None
|
|
61
|
+
"""Directory containing the user's app file. If not provided, resolved from env."""
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def resolved_base_dir(self) -> Path:
|
|
65
|
+
"""Resolve the base directory where relative paths should be anchored.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Resolved base directory path.
|
|
69
|
+
|
|
70
|
+
Resolution precedence:
|
|
71
|
+
1. Explicit `base_dir` if provided
|
|
72
|
+
2. Directory of PULSE_APP_FILE env var
|
|
73
|
+
3. PULSE_APP_DIR env var
|
|
74
|
+
4. Current working directory
|
|
75
|
+
"""
|
|
76
|
+
if isinstance(self.base_dir, Path):
|
|
77
|
+
return self.base_dir
|
|
78
|
+
app_file = env.pulse_app_file
|
|
79
|
+
if app_file:
|
|
80
|
+
return Path(app_file).parent
|
|
81
|
+
app_dir = env.pulse_app_dir
|
|
82
|
+
if app_dir:
|
|
83
|
+
return Path(app_dir)
|
|
84
|
+
return Path.cwd()
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def web_root(self) -> Path:
|
|
88
|
+
"""Absolute path to the web root directory.
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Absolute path to web_dir (e.g., `<base_dir>/web`).
|
|
92
|
+
"""
|
|
93
|
+
wd = Path(self.web_dir)
|
|
94
|
+
if wd.is_absolute():
|
|
95
|
+
return wd
|
|
96
|
+
return self.resolved_base_dir / wd
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def pulse_path(self) -> Path:
|
|
100
|
+
"""Full path to the generated Pulse app directory.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
Absolute path where generated files are written
|
|
104
|
+
(e.g., `<web_root>/app/<pulse_dir>`).
|
|
105
|
+
"""
|
|
106
|
+
return self.web_root / "app" / self.pulse_dir
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_file_if_changed(path: Path, content: str | bytes) -> Path:
|
|
110
|
+
"""Write content to file only if it has changed."""
|
|
111
|
+
if path.exists():
|
|
112
|
+
try:
|
|
113
|
+
if isinstance(content, bytes):
|
|
114
|
+
current_content = path.read_bytes()
|
|
115
|
+
else:
|
|
116
|
+
current_content = path.read_text()
|
|
117
|
+
if current_content == content:
|
|
118
|
+
return path # Skip writing, content is the same
|
|
119
|
+
except Exception as exc:
|
|
120
|
+
logging.warning("Can't read file %s: %s", path.absolute(), exc)
|
|
121
|
+
# If we can't read the file for any reason, just write it
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
path.parent.mkdir(exist_ok=True, parents=True)
|
|
125
|
+
if isinstance(content, bytes):
|
|
126
|
+
path.write_bytes(content)
|
|
127
|
+
else:
|
|
128
|
+
path.write_text(content)
|
|
129
|
+
return path
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Codegen:
|
|
133
|
+
cfg: CodegenConfig
|
|
134
|
+
routes: RouteTree
|
|
135
|
+
|
|
136
|
+
def __init__(self, routes: RouteTree, config: CodegenConfig) -> None:
|
|
137
|
+
self.cfg = config
|
|
138
|
+
self.routes = routes
|
|
139
|
+
self._copied_files: set[Path] = set()
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def output_folder(self):
|
|
143
|
+
return self.cfg.pulse_path
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def assets_folder(self):
|
|
147
|
+
return self.output_folder / "assets"
|
|
148
|
+
|
|
149
|
+
def generate_all(
|
|
150
|
+
self,
|
|
151
|
+
server_address: str,
|
|
152
|
+
internal_server_address: str | None = None,
|
|
153
|
+
api_prefix: str = "",
|
|
154
|
+
connection_status: "ConnectionStatusConfig | None" = None,
|
|
155
|
+
):
|
|
156
|
+
# Ensure generated files are gitignored
|
|
157
|
+
ensure_gitignore_has(self.cfg.web_root, f"app/{self.cfg.pulse_dir}/")
|
|
158
|
+
|
|
159
|
+
self._copied_files = set()
|
|
160
|
+
|
|
161
|
+
# Copy all registered local files to the assets directory
|
|
162
|
+
self._copy_local_files()
|
|
163
|
+
|
|
164
|
+
# Keep track of all generated files
|
|
165
|
+
generated_files = set(
|
|
166
|
+
[
|
|
167
|
+
self.generate_layout_tsx(
|
|
168
|
+
server_address,
|
|
169
|
+
internal_server_address,
|
|
170
|
+
api_prefix,
|
|
171
|
+
connection_status,
|
|
172
|
+
),
|
|
173
|
+
self.generate_routes_ts(),
|
|
174
|
+
self.generate_routes_runtime_ts(),
|
|
175
|
+
*(
|
|
176
|
+
self.generate_route(route, server_address=server_address)
|
|
177
|
+
for route in self.routes.flat_tree.values()
|
|
178
|
+
),
|
|
179
|
+
]
|
|
180
|
+
)
|
|
181
|
+
generated_files.update(self._copied_files)
|
|
182
|
+
|
|
183
|
+
# Clean up any remaining files that are not part of the generated files
|
|
184
|
+
for path in self.output_folder.rglob("*"):
|
|
185
|
+
if path.is_file() and path not in generated_files:
|
|
186
|
+
try:
|
|
187
|
+
path.unlink()
|
|
188
|
+
logger.debug(f"Removed stale file: {path}")
|
|
189
|
+
except Exception as e:
|
|
190
|
+
logger.warning(f"Could not remove stale file {path}: {e}")
|
|
191
|
+
|
|
192
|
+
def _copy_local_files(self) -> None:
|
|
193
|
+
"""Copy all registered local assets to the assets directory.
|
|
194
|
+
|
|
195
|
+
Uses the unified asset registry which tracks local files from both
|
|
196
|
+
Import objects and DynamicImport expressions.
|
|
197
|
+
"""
|
|
198
|
+
assets = get_registered_assets()
|
|
199
|
+
|
|
200
|
+
if not assets:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
self.assets_folder.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
for asset in assets:
|
|
206
|
+
dest_path = self.assets_folder / asset.asset_filename
|
|
207
|
+
|
|
208
|
+
# Copy file if source exists
|
|
209
|
+
if asset.source_path.exists():
|
|
210
|
+
self._copied_files.add(dest_path)
|
|
211
|
+
try:
|
|
212
|
+
content = asset.source_path.read_bytes()
|
|
213
|
+
except OSError as exc:
|
|
214
|
+
logger.warning(
|
|
215
|
+
"Can't read asset %s: %s",
|
|
216
|
+
asset.source_path,
|
|
217
|
+
exc,
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
write_file_if_changed(dest_path, content)
|
|
221
|
+
|
|
222
|
+
def generate_layout_tsx(
|
|
223
|
+
self,
|
|
224
|
+
server_address: str,
|
|
225
|
+
internal_server_address: str | None = None,
|
|
226
|
+
api_prefix: str = "",
|
|
227
|
+
connection_status: "ConnectionStatusConfig | None" = None,
|
|
228
|
+
):
|
|
229
|
+
"""Generates the content of _layout.tsx"""
|
|
230
|
+
from pulse.app import ConnectionStatusConfig
|
|
231
|
+
|
|
232
|
+
connection_status = connection_status or ConnectionStatusConfig()
|
|
233
|
+
content = str(
|
|
234
|
+
LAYOUT_TEMPLATE.render_unicode(
|
|
235
|
+
server_address=server_address,
|
|
236
|
+
internal_server_address=internal_server_address or server_address,
|
|
237
|
+
api_prefix=api_prefix,
|
|
238
|
+
connection_status=connection_status,
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
# The underscore avoids an eventual naming conflict with a generated
|
|
242
|
+
# /layout route.
|
|
243
|
+
return write_file_if_changed(self.output_folder / "_layout.tsx", content)
|
|
244
|
+
|
|
245
|
+
def generate_routes_ts(self):
|
|
246
|
+
"""Generate TypeScript code for the routes configuration."""
|
|
247
|
+
routes_str = self._render_routes_ts(self.routes.tree, 2)
|
|
248
|
+
content = str(
|
|
249
|
+
ROUTES_CONFIG_TEMPLATE.render_unicode(
|
|
250
|
+
routes_str=routes_str,
|
|
251
|
+
pulse_dir=self.cfg.pulse_dir,
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
return write_file_if_changed(self.output_folder / "routes.ts", content)
|
|
255
|
+
|
|
256
|
+
def generate_routes_runtime_ts(self):
|
|
257
|
+
"""Generate a runtime React Router object tree for server-side matching."""
|
|
258
|
+
routes_str = self._render_routes_runtime(self.routes.tree, indent_level=0)
|
|
259
|
+
content = str(
|
|
260
|
+
ROUTES_RUNTIME_TEMPLATE.render_unicode(
|
|
261
|
+
routes_str=routes_str,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
return write_file_if_changed(self.output_folder / "routes.runtime.ts", content)
|
|
265
|
+
|
|
266
|
+
def _render_routes_ts(
|
|
267
|
+
self, routes: Sequence[Route | Layout], indent_level: int
|
|
268
|
+
) -> str:
|
|
269
|
+
lines: list[str] = []
|
|
270
|
+
indent_str = " " * indent_level
|
|
271
|
+
for route in routes:
|
|
272
|
+
if isinstance(route, Layout):
|
|
273
|
+
children_str = ""
|
|
274
|
+
if route.children:
|
|
275
|
+
children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
|
|
276
|
+
lines.append(
|
|
277
|
+
f'{indent_str}layout("{self.cfg.pulse_dir}/layouts/{route.file_path()}", [{children_str}]),'
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
if route.children:
|
|
281
|
+
children_str = f"\n{self._render_routes_ts(route.children, indent_level + 1)}\n{indent_str}"
|
|
282
|
+
lines.append(
|
|
283
|
+
f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}", [{children_str}]),'
|
|
284
|
+
)
|
|
285
|
+
elif route.is_index:
|
|
286
|
+
lines.append(
|
|
287
|
+
f'{indent_str}index("{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
|
|
288
|
+
)
|
|
289
|
+
else:
|
|
290
|
+
lines.append(
|
|
291
|
+
f'{indent_str}route("{route.path}", "{self.cfg.pulse_dir}/routes/{route.file_path()}"),'
|
|
292
|
+
)
|
|
293
|
+
return "\n".join(lines)
|
|
294
|
+
|
|
295
|
+
def generate_route(
|
|
296
|
+
self,
|
|
297
|
+
route: Route | Layout,
|
|
298
|
+
server_address: str,
|
|
299
|
+
):
|
|
300
|
+
route_file_path = route.file_path()
|
|
301
|
+
if isinstance(route, Layout):
|
|
302
|
+
output_path = self.output_folder / "layouts" / route_file_path
|
|
303
|
+
full_route_path = f"layouts/{route_file_path}"
|
|
304
|
+
else:
|
|
305
|
+
output_path = self.output_folder / "routes" / route_file_path
|
|
306
|
+
full_route_path = f"routes/{route_file_path}"
|
|
307
|
+
|
|
308
|
+
content = generate_route(
|
|
309
|
+
path=route.unique_path(),
|
|
310
|
+
route_file_path=full_route_path,
|
|
311
|
+
)
|
|
312
|
+
return write_file_if_changed(output_path, content)
|
|
313
|
+
|
|
314
|
+
def _render_routes_runtime(
|
|
315
|
+
self, routes: list[Route | Layout], indent_level: int
|
|
316
|
+
) -> str:
|
|
317
|
+
"""
|
|
318
|
+
Render an array of RRRouteObject literals suitable for matchRoutes.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def render_node(node: Route | Layout, indent: int) -> str:
|
|
322
|
+
ind = " " * indent
|
|
323
|
+
lines: list[str] = [f"{ind}{{"]
|
|
324
|
+
# Common: id and uniquePath
|
|
325
|
+
lines.append(f'{ind} id: "{node.unique_path()}",')
|
|
326
|
+
lines.append(f'{ind} uniquePath: "{node.unique_path()}",')
|
|
327
|
+
if isinstance(node, Layout):
|
|
328
|
+
# Pathless layout
|
|
329
|
+
lines.append(
|
|
330
|
+
f'{ind} file: "{self.cfg.pulse_dir}/layouts/{node.file_path()}",'
|
|
331
|
+
)
|
|
332
|
+
else:
|
|
333
|
+
# Route: index vs path
|
|
334
|
+
if node.is_index:
|
|
335
|
+
lines.append(f"{ind} index: true,")
|
|
336
|
+
else:
|
|
337
|
+
lines.append(f'{ind} path: "{node.path}",')
|
|
338
|
+
lines.append(
|
|
339
|
+
f'{ind} file: "{self.cfg.pulse_dir}/routes/{node.file_path()}",'
|
|
340
|
+
)
|
|
341
|
+
if node.children:
|
|
342
|
+
lines.append(f"{ind} children: [")
|
|
343
|
+
for c in node.children:
|
|
344
|
+
lines.append(render_node(c, indent + 2))
|
|
345
|
+
lines.append(f"{ind} ,")
|
|
346
|
+
if lines[-1] == f"{ind} ,":
|
|
347
|
+
lines.pop()
|
|
348
|
+
lines.append(f"{ind} ],")
|
|
349
|
+
lines.append(f"{ind}}}")
|
|
350
|
+
return "\n".join(lines)
|
|
351
|
+
|
|
352
|
+
ind = " " * indent_level
|
|
353
|
+
out: list[str] = [f"{ind}["]
|
|
354
|
+
for index, r in enumerate(routes):
|
|
355
|
+
out.append(render_node(r, indent_level + 1))
|
|
356
|
+
if index != len(routes) - 1:
|
|
357
|
+
out.append(f"{ind} ,")
|
|
358
|
+
out.append(f"{ind}]")
|
|
359
|
+
return "\n".join(out)
|
|
File without changes
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from mako.template import Template
|
|
2
|
+
|
|
3
|
+
LAYOUT_TEMPLATE = Template(
|
|
4
|
+
"""import { deserialize, extractServerRouteInfo, PulseProvider, type PulseConfig, type PulsePrerender } from "pulse-ui-client";
|
|
5
|
+
import { Outlet, data, type LoaderFunctionArgs, type ClientLoaderFunctionArgs } from "react-router";
|
|
6
|
+
import { matchRoutes } from "react-router";
|
|
7
|
+
import { rrPulseRouteTree } from "./routes.runtime";
|
|
8
|
+
import { useLoaderData } from "react-router";
|
|
9
|
+
|
|
10
|
+
// This config is used to initialize the client
|
|
11
|
+
export const config: PulseConfig = {
|
|
12
|
+
serverAddress: "${server_address}",
|
|
13
|
+
apiPrefix: "${api_prefix}",
|
|
14
|
+
connectionStatus: {
|
|
15
|
+
initialConnectingDelay: ${int(connection_status.initial_connecting_delay * 1000)},
|
|
16
|
+
initialErrorDelay: ${int(connection_status.initial_error_delay * 1000)},
|
|
17
|
+
reconnectErrorDelay: ${int(connection_status.reconnect_error_delay * 1000)},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
// Server loader: perform initial prerender, abort on first redirect/not-found
|
|
23
|
+
export async function loader(args: LoaderFunctionArgs) {
|
|
24
|
+
const url = new URL(args.request.url);
|
|
25
|
+
const matches = matchRoutes(rrPulseRouteTree, url.pathname) ?? [];
|
|
26
|
+
const paths = matches.map(m => m.route.uniquePath);
|
|
27
|
+
// Build minimal, safe headers for cross-origin API call
|
|
28
|
+
const incoming = args.request.headers;
|
|
29
|
+
const fwd = new Headers();
|
|
30
|
+
const cookie = incoming.get("cookie");
|
|
31
|
+
const authorization = incoming.get("authorization");
|
|
32
|
+
if (cookie) fwd.set("cookie", cookie);
|
|
33
|
+
if (authorization) fwd.set("authorization", authorization);
|
|
34
|
+
fwd.set("content-type", "application/json");
|
|
35
|
+
const res = await fetch(`${internal_server_address}$${"{"}config.apiPrefix}/prerender`, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: fwd,
|
|
38
|
+
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args) }),
|
|
39
|
+
});
|
|
40
|
+
if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
|
|
41
|
+
const body = await res.json();
|
|
42
|
+
if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
|
|
43
|
+
if (body.notFound) {
|
|
44
|
+
console.error("Not found:", url.pathname);
|
|
45
|
+
throw new Response("Not Found", { status: 404 });
|
|
46
|
+
}
|
|
47
|
+
const prerenderData = deserialize(body) as PulsePrerender;
|
|
48
|
+
const setCookies =
|
|
49
|
+
(res.headers.getSetCookie?.() as string[] | undefined) ??
|
|
50
|
+
(res.headers.get("set-cookie") ? [res.headers.get("set-cookie") as string] : []);
|
|
51
|
+
const headers = new Headers();
|
|
52
|
+
for (const c of setCookies) headers.append("Set-Cookie", c);
|
|
53
|
+
return data(prerenderData, { headers });
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Client loader: re-prerender on navigation while reusing renderId
|
|
57
|
+
export async function clientLoader(args: ClientLoaderFunctionArgs) {
|
|
58
|
+
const url = new URL(args.request.url);
|
|
59
|
+
const matches = matchRoutes(rrPulseRouteTree, url.pathname) ?? [];
|
|
60
|
+
const paths = matches.map(m => m.route.uniquePath);
|
|
61
|
+
const renderId =
|
|
62
|
+
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
63
|
+
? (sessionStorage.getItem("__PULSE_RENDER_ID") ?? undefined)
|
|
64
|
+
: undefined;
|
|
65
|
+
const directives =
|
|
66
|
+
typeof window !== "undefined" && typeof sessionStorage !== "undefined"
|
|
67
|
+
? (JSON.parse(sessionStorage.getItem("__PULSE_DIRECTIVES") ?? "{}"))
|
|
68
|
+
: {};
|
|
69
|
+
const headers: HeadersInit = { "content-type": "application/json" };
|
|
70
|
+
if (directives?.headers) {
|
|
71
|
+
for (const [key, value] of Object.entries(directives.headers)) {
|
|
72
|
+
headers[key] = value as string;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const res = await fetch(`$${"{"}config.serverAddress}$${"{"}config.apiPrefix}/prerender`, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers,
|
|
78
|
+
credentials: "include",
|
|
79
|
+
body: JSON.stringify({ paths, routeInfo: extractServerRouteInfo(args), renderId }),
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) throw new Error("Failed to prerender batch:" + res.status);
|
|
82
|
+
const body = await res.json();
|
|
83
|
+
if (body.redirect) return new Response(null, { status: 302, headers: { Location: body.redirect } });
|
|
84
|
+
if (body.notFound) throw new Response("Not Found", { status: 404 });
|
|
85
|
+
const prerenderData = deserialize(body) as PulsePrerender;
|
|
86
|
+
if (typeof window !== "undefined" && typeof sessionStorage !== "undefined" && prerenderData.directives) {
|
|
87
|
+
sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(prerenderData.directives));
|
|
88
|
+
}
|
|
89
|
+
return prerenderData as PulsePrerender;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export default function PulseLayout() {
|
|
93
|
+
const data = useLoaderData<typeof loader>();
|
|
94
|
+
if (typeof window !== "undefined" && typeof sessionStorage !== "undefined") {
|
|
95
|
+
sessionStorage.setItem("__PULSE_RENDER_ID", data.renderId);
|
|
96
|
+
sessionStorage.setItem("__PULSE_DIRECTIVES", JSON.stringify(data.directives));
|
|
97
|
+
}
|
|
98
|
+
return (
|
|
99
|
+
<PulseProvider config={config} prerender={data}>
|
|
100
|
+
<Outlet />
|
|
101
|
+
</PulseProvider>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
// Persist renderId and directives in sessionStorage for reuse in clientLoader is handled within the component
|
|
105
|
+
"""
|
|
106
|
+
)
|