create-caspian-app 1.0.0 → 1.0.2

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.
@@ -1,7 +1,7 @@
1
- {
2
- "local": "http://localhost:5090",
3
- "external": "http://192.168.1.62:5090",
4
- "ui": "http://localhost:3001",
5
- "uiExternal": "http://192.168.1.62:3001",
6
- "backend": "http://localhost:5200"
1
+ {
2
+ "local": "http://localhost:5090",
3
+ "external": "http://192.168.1.62:5090",
4
+ "ui": "http://localhost:3001",
5
+ "uiExternal": "http://192.168.1.62:3001",
6
+ "backend": "http://localhost:5200"
7
7
  }
@@ -1,290 +1,290 @@
1
- """Static site exporter for this Caspian app (SSG, like Next.js `output: export`).
2
-
3
- Boots the real ASGI app in-process and requests every route through httpx2's
4
- ``ASGITransport`` (the app's own HTTP client, same as the test suite), so the
5
- exported HTML is byte-identical to what the dev server serves (layouts,
6
- components, PulsePoint deferral, security headers -- the whole pipeline). The
7
- app's real lifespan is run around the export so startup/shutdown state matches a
8
- live server. Output goes to ``static/`` as ``<route>/index.html`` plus a copy of
9
- the public assets.
10
-
11
- Scope policy: "warn & skip". Routes that cannot be fully static are NOT written;
12
- each is reported so nothing broken ships silently:
13
- - dynamic routes (``[id]`` / ``[...slug]``) need an explicit path list
14
- - auth-gated routes redirect instead of returning a page
15
- - non-GET-only routes / error responses
16
-
17
- Run via ``npm run static`` (builds Tailwind first, then this script).
18
-
19
- Caveats that hold for ANY static build of this app:
20
- - ``pp.rpc()`` server actions, auth/sessions, WebSockets, streaming and
21
- per-request server data do NOT work without the Python backend. Pages that
22
- rely on them still render, but those interactions are dead in the output.
23
- - Asset URLs are root-absolute (``/css/...``, ``/js/...``). Serve ``static/``
24
- at a domain root, or rewrite the base path for a subdirectory deploy.
25
- """
26
-
27
- from __future__ import annotations
28
-
29
- import asyncio
30
- import inspect
31
- import os
32
- import re
33
- import shutil
34
- import stat
35
- import sys
36
- import time
37
- from pathlib import Path
38
- from typing import Any
39
-
40
- # Run from the project root so relative paths (public/, src/app/, config) resolve
41
- # exactly like the running server does.
42
- ROOT = Path(__file__).resolve().parent.parent
43
- os.chdir(ROOT)
44
- sys.path.insert(0, str(ROOT))
45
-
46
- # Keep the app in non-production mode: dev generates an ephemeral session secret,
47
- # so the build does not require production secrets to be present.
48
- os.environ.setdefault("APP_ENV", "development")
49
-
50
- import httpx2 # noqa: E402 (the app's HTTP client; drives the ASGI app in-process)
51
-
52
- import main # noqa: E402 (imports boot the app and register all routes)
53
- from casp.caspian_config import get_files_index # noqa: E402
54
-
55
- OUT_DIR = ROOT / "static"
56
- PUBLIC_DIR = ROOT / "public"
57
-
58
- # Public asset trees to mirror into static/ (source name -> output name).
59
- ASSET_DIRS = ("css", "js", "assets", "uploads")
60
- ASSET_FILES = ("favicon.ico",)
61
-
62
- GREEN = "\033[32m"
63
- YELLOW = "\033[33m"
64
- RED = "\033[31m"
65
- DIM = "\033[2m"
66
- RESET = "\033[0m"
67
-
68
-
69
- def _route_py_path(route) -> str:
70
- base = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
71
- return f"{base}/index.py".replace("//", "/")
72
-
73
-
74
- async def _resolve_static_paths(route) -> list | None:
75
- """Return concrete param sets for a dynamic route, or None if not declared.
76
-
77
- A dynamic route pre-renders itself by exporting ``static_paths`` in its
78
- ``index.py`` -- Caspian's equivalent of Next.js ``getStaticPaths``. It may be:
79
- - a list of dicts: [{"id": 1}, {"id": 2}]
80
- - a list of scalars: [1, 2] (mapped onto the route's single param)
81
- - a callable (sync or async) returning either of the above
82
- """
83
- if not route.has_py:
84
- return None
85
- module = main.load_route_module(_route_py_path(route))
86
- provider = getattr(module, "static_paths", None)
87
- if provider is None:
88
- return None
89
- result: Any = provider() if callable(provider) else provider
90
- if inspect.isawaitable(result):
91
- # The whole export already runs inside a single event loop, so an async
92
- # provider is awaited directly -- no nested loop.
93
- result = await result
94
- return list(result)
95
-
96
-
97
- def _fill_rule(fastapi_rule: str, params) -> str:
98
- """Substitute a param set into a FastAPI rule -> a concrete URL path."""
99
- names = re.findall(r"{(\w+)(?::path)?}", fastapi_rule)
100
- if not isinstance(params, dict):
101
- # Scalar convenience for single-parameter routes like /todo/[id].
102
- params = {names[0]: params} if names else {}
103
- url = fastapi_rule
104
- for key, value in params.items():
105
- url = url.replace(f"{{{key}:path}}", str(value)).replace(f"{{{key}}}", str(value))
106
- return url
107
-
108
-
109
- def _out_path_for(url_path: str) -> Path:
110
- """Map a URL path to its static file: '/' -> index.html, '/x' -> x/index.html."""
111
- clean = url_path.strip("/")
112
- if not clean:
113
- return OUT_DIR / "index.html"
114
- return OUT_DIR / clean / "index.html"
115
-
116
-
117
- def _handle_locked_removal(func, path, exc) -> None:
118
- """``shutil.rmtree`` onexc hook: clear a read-only bit and retry the delete.
119
-
120
- Handles the common Windows case where a file is read-only (``os.remove`` /
121
- ``os.rmdir`` raise ``PermissionError``); anything still failing is left for
122
- the retry loop in ``_reset_out_dir`` to re-evaluate or report.
123
- """
124
- try:
125
- os.chmod(path, stat.S_IWRITE)
126
- func(path)
127
- except OSError:
128
- pass
129
-
130
-
131
- def _reset_out_dir(out_dir: Path, attempts: int = 5, delay: float = 0.4) -> bool:
132
- """Empty ``static/`` in place and return True on success.
133
-
134
- Deliberately keeps the top-level ``static/`` directory instead of removing
135
- it. On Windows, ``rmtree(static/)`` fails with ``WinError 32`` ("used by
136
- another process") whenever anything holds a handle on the directory itself --
137
- a shell cwd'd into it, an open File Explorer window, an editor, an
138
- antivirus/indexer scan, or a still-running ``npm run static:serve``. Clearing
139
- the *contents* and rewriting them sidesteps that lock. Transient child locks
140
- get a few retries before we give up with an actionable message.
141
- """
142
- if not out_dir.exists():
143
- out_dir.mkdir(parents=True, exist_ok=True)
144
- return True
145
-
146
- for _ in range(attempts):
147
- if not any(out_dir.iterdir()):
148
- return True
149
- for entry in out_dir.iterdir():
150
- try:
151
- if entry.is_dir() and not entry.is_symlink():
152
- shutil.rmtree(entry, onexc=_handle_locked_removal)
153
- else:
154
- try:
155
- entry.unlink()
156
- except PermissionError:
157
- os.chmod(entry, stat.S_IWRITE)
158
- entry.unlink()
159
- except OSError:
160
- pass # re-evaluated on the next pass
161
- if not any(out_dir.iterdir()):
162
- return True
163
- time.sleep(delay)
164
-
165
- stuck = ", ".join(p.name for p in out_dir.iterdir()) or out_dir.name
166
- print(
167
- f"{RED}Could not clear the static/ output directory.{RESET}\n"
168
- f"{DIM} Still locked: {stuck}\n"
169
- f" Something is holding a handle on it. Close whatever is using static/\n"
170
- f" and retry -- a running `npm run static:serve`, a File Explorer window\n"
171
- f" or terminal open inside static/, or an editor previewing an exported\n"
172
- f" file are the usual causes.{RESET}"
173
- )
174
- return False
175
-
176
-
177
- def _copy_assets() -> None:
178
- for name in ASSET_DIRS:
179
- src = PUBLIC_DIR / name
180
- if src.is_dir():
181
- dst = OUT_DIR / name
182
- shutil.copytree(src, dst, dirs_exist_ok=True)
183
- print(f"{DIM} copied public/{name}/ -> static/{name}/{RESET}")
184
- for name in ASSET_FILES:
185
- src = PUBLIC_DIR / name
186
- if src.is_file():
187
- shutil.copy2(src, OUT_DIR / name)
188
- print(f"{DIM} copied public/{name} -> static/{name}{RESET}")
189
-
190
-
191
- def build() -> int:
192
- idx = get_files_index()
193
-
194
- if not _reset_out_dir(OUT_DIR):
195
- return 1
196
-
197
- exported: list[str] = []
198
- skipped: list[tuple[str, str]] = []
199
- rpc_warnings: list[str] = []
200
-
201
- print(f"\n{GREEN}Caspian static export{RESET} -> {OUT_DIR}\n")
202
-
203
- async def export_one(client, url: str) -> None:
204
- resp = await client.get(url, follow_redirects=False)
205
-
206
- if resp.status_code in (301, 302, 303, 307, 308):
207
- target = resp.headers.get("location", "?")
208
- skipped.append(
209
- (url, f"redirects to {target} -- likely auth-gated, needs the server")
210
- )
211
- return
212
-
213
- if resp.status_code != 200:
214
- skipped.append((url, f"returned HTTP {resp.status_code}"))
215
- return
216
-
217
- if "text/html" not in resp.headers.get("content-type", ""):
218
- skipped.append((url, "non-HTML response"))
219
- return
220
-
221
- html = resp.text
222
- out_file = _out_path_for(url)
223
- out_file.parent.mkdir(parents=True, exist_ok=True)
224
- out_file.write_text(html, encoding="utf-8")
225
- exported.append(url)
226
-
227
- # Heuristic: flag pages whose interactivity depends on the backend.
228
- if "pp.rpc" in html or "X-PP-RPC" in html or "pp-rpc" in html:
229
- rpc_warnings.append(url)
230
-
231
- async def render_all() -> None:
232
- # Drive the ASGI app in-process with httpx2 (same as the test suite).
233
- # ASGITransport does not run lifespan on its own, so enter the app's real
234
- # lifespan context to mirror a live server's startup/shutdown state.
235
- transport = httpx2.ASGITransport(app=main.app)
236
- async with main.app.router.lifespan_context(main.app):
237
- async with httpx2.AsyncClient(
238
- transport=transport, base_url="http://testserver"
239
- ) as client:
240
- for route in idx.routes:
241
- url = route.url_path
242
-
243
- # Dynamic segments: pre-render only what the route declares via
244
- # static_paths() (the getStaticPaths equivalent). Otherwise skip.
245
- if "{" in route.fastapi_rule:
246
- param_sets = await _resolve_static_paths(route)
247
- if not param_sets:
248
- skipped.append(
249
- (url, "dynamic route -- add static_paths() to its index.py to pre-render (like getStaticPaths)")
250
- )
251
- continue
252
- for params in param_sets:
253
- await export_one(client, _fill_rule(route.fastapi_rule, params))
254
- continue
255
-
256
- await export_one(client, url)
257
-
258
- asyncio.run(render_all())
259
-
260
- _copy_assets()
261
-
262
- # ---- Report ----
263
- print(f"\n{GREEN}Exported {len(exported)} page(s):{RESET}")
264
- for url in exported:
265
- rel = _out_path_for(url).relative_to(ROOT)
266
- print(f" {GREEN}OK{RESET} {url} -> {rel}")
267
-
268
- if rpc_warnings:
269
- print(f"\n{YELLOW}Note: these pages appear to use pp.rpc()/server actions{RESET}")
270
- print(f"{DIM} (they render, but those interactions are dead without the backend):{RESET}")
271
- for url in rpc_warnings:
272
- print(f" {YELLOW}!{RESET} {url}")
273
-
274
- if skipped:
275
- print(f"\n{YELLOW}Skipped {len(skipped)} route(s):{RESET}")
276
- for url, reason in skipped:
277
- print(f" {YELLOW}SKIP{RESET} {url} ({reason})")
278
-
279
- print(
280
- f"\n{GREEN}Done.{RESET} Preview it over HTTP: {GREEN}npm run static:serve{RESET} "
281
- f"-> http://localhost:8000\n"
282
- f"{DIM} Do NOT double-click static/index.html (file://): root-absolute asset\n"
283
- f" paths break and browsers block ES module scripts from file:// origins.\n"
284
- f" Deploy the static/ folder to any HTTP host (Netlify, Vercel, GitHub Pages, nginx).{RESET}\n"
285
- )
286
- return 0
287
-
288
-
289
- if __name__ == "__main__":
290
- raise SystemExit(build())
1
+ """Static site exporter for this Caspian app (SSG, like Next.js `output: export`).
2
+
3
+ Boots the real ASGI app in-process and requests every route through httpx2's
4
+ ``ASGITransport`` (the app's own HTTP client, same as the test suite), so the
5
+ exported HTML is byte-identical to what the dev server serves (layouts,
6
+ components, PulsePoint deferral, security headers -- the whole pipeline). The
7
+ app's real lifespan is run around the export so startup/shutdown state matches a
8
+ live server. Output goes to ``static/`` as ``<route>/index.html`` plus a copy of
9
+ the public assets.
10
+
11
+ Scope policy: "warn & skip". Routes that cannot be fully static are NOT written;
12
+ each is reported so nothing broken ships silently:
13
+ - dynamic routes (``[id]`` / ``[...slug]``) need an explicit path list
14
+ - auth-gated routes redirect instead of returning a page
15
+ - non-GET-only routes / error responses
16
+
17
+ Run via ``npm run static`` (builds Tailwind first, then this script).
18
+
19
+ Caveats that hold for ANY static build of this app:
20
+ - ``pp.rpc()`` server actions, auth/sessions, WebSockets, streaming and
21
+ per-request server data do NOT work without the Python backend. Pages that
22
+ rely on them still render, but those interactions are dead in the output.
23
+ - Asset URLs are root-absolute (``/css/...``, ``/js/...``). Serve ``static/``
24
+ at a domain root, or rewrite the base path for a subdirectory deploy.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import inspect
31
+ import os
32
+ import re
33
+ import shutil
34
+ import stat
35
+ import sys
36
+ import time
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ # Run from the project root so relative paths (public/, src/app/, config) resolve
41
+ # exactly like the running server does.
42
+ ROOT = Path(__file__).resolve().parent.parent
43
+ os.chdir(ROOT)
44
+ sys.path.insert(0, str(ROOT))
45
+
46
+ # Keep the app in non-production mode: dev generates an ephemeral session secret,
47
+ # so the build does not require production secrets to be present.
48
+ os.environ.setdefault("APP_ENV", "development")
49
+
50
+ import httpx2 # noqa: E402 (the app's HTTP client; drives the ASGI app in-process)
51
+
52
+ import main # noqa: E402 (imports boot the app and register all routes)
53
+ from casp.caspian_config import get_files_index # noqa: E402
54
+
55
+ OUT_DIR = ROOT / "static"
56
+ PUBLIC_DIR = ROOT / "public"
57
+
58
+ # Public asset trees to mirror into static/ (source name -> output name).
59
+ ASSET_DIRS = ("css", "js", "assets", "uploads")
60
+ ASSET_FILES = ("favicon.ico",)
61
+
62
+ GREEN = "\033[32m"
63
+ YELLOW = "\033[33m"
64
+ RED = "\033[31m"
65
+ DIM = "\033[2m"
66
+ RESET = "\033[0m"
67
+
68
+
69
+ def _route_py_path(route) -> str:
70
+ base = f"src/app/{route.fs_dir}" if route.fs_dir else "src/app"
71
+ return f"{base}/index.py".replace("//", "/")
72
+
73
+
74
+ async def _resolve_static_paths(route) -> list | None:
75
+ """Return concrete param sets for a dynamic route, or None if not declared.
76
+
77
+ A dynamic route pre-renders itself by exporting ``static_paths`` in its
78
+ ``index.py`` -- Caspian's equivalent of Next.js ``getStaticPaths``. It may be:
79
+ - a list of dicts: [{"id": 1}, {"id": 2}]
80
+ - a list of scalars: [1, 2] (mapped onto the route's single param)
81
+ - a callable (sync or async) returning either of the above
82
+ """
83
+ if not route.has_py:
84
+ return None
85
+ module = main.load_route_module(_route_py_path(route))
86
+ provider = getattr(module, "static_paths", None)
87
+ if provider is None:
88
+ return None
89
+ result: Any = provider() if callable(provider) else provider
90
+ if inspect.isawaitable(result):
91
+ # The whole export already runs inside a single event loop, so an async
92
+ # provider is awaited directly -- no nested loop.
93
+ result = await result
94
+ return list(result)
95
+
96
+
97
+ def _fill_rule(fastapi_rule: str, params) -> str:
98
+ """Substitute a param set into a FastAPI rule -> a concrete URL path."""
99
+ names = re.findall(r"{(\w+)(?::path)?}", fastapi_rule)
100
+ if not isinstance(params, dict):
101
+ # Scalar convenience for single-parameter routes like /todo/[id].
102
+ params = {names[0]: params} if names else {}
103
+ url = fastapi_rule
104
+ for key, value in params.items():
105
+ url = url.replace(f"{{{key}:path}}", str(value)).replace(f"{{{key}}}", str(value))
106
+ return url
107
+
108
+
109
+ def _out_path_for(url_path: str) -> Path:
110
+ """Map a URL path to its static file: '/' -> index.html, '/x' -> x/index.html."""
111
+ clean = url_path.strip("/")
112
+ if not clean:
113
+ return OUT_DIR / "index.html"
114
+ return OUT_DIR / clean / "index.html"
115
+
116
+
117
+ def _handle_locked_removal(func, path, exc) -> None:
118
+ """``shutil.rmtree`` onexc hook: clear a read-only bit and retry the delete.
119
+
120
+ Handles the common Windows case where a file is read-only (``os.remove`` /
121
+ ``os.rmdir`` raise ``PermissionError``); anything still failing is left for
122
+ the retry loop in ``_reset_out_dir`` to re-evaluate or report.
123
+ """
124
+ try:
125
+ os.chmod(path, stat.S_IWRITE)
126
+ func(path)
127
+ except OSError:
128
+ pass
129
+
130
+
131
+ def _reset_out_dir(out_dir: Path, attempts: int = 5, delay: float = 0.4) -> bool:
132
+ """Empty ``static/`` in place and return True on success.
133
+
134
+ Deliberately keeps the top-level ``static/`` directory instead of removing
135
+ it. On Windows, ``rmtree(static/)`` fails with ``WinError 32`` ("used by
136
+ another process") whenever anything holds a handle on the directory itself --
137
+ a shell cwd'd into it, an open File Explorer window, an editor, an
138
+ antivirus/indexer scan, or a still-running ``npm run static:serve``. Clearing
139
+ the *contents* and rewriting them sidesteps that lock. Transient child locks
140
+ get a few retries before we give up with an actionable message.
141
+ """
142
+ if not out_dir.exists():
143
+ out_dir.mkdir(parents=True, exist_ok=True)
144
+ return True
145
+
146
+ for _ in range(attempts):
147
+ if not any(out_dir.iterdir()):
148
+ return True
149
+ for entry in out_dir.iterdir():
150
+ try:
151
+ if entry.is_dir() and not entry.is_symlink():
152
+ shutil.rmtree(entry, onexc=_handle_locked_removal)
153
+ else:
154
+ try:
155
+ entry.unlink()
156
+ except PermissionError:
157
+ os.chmod(entry, stat.S_IWRITE)
158
+ entry.unlink()
159
+ except OSError:
160
+ pass # re-evaluated on the next pass
161
+ if not any(out_dir.iterdir()):
162
+ return True
163
+ time.sleep(delay)
164
+
165
+ stuck = ", ".join(p.name for p in out_dir.iterdir()) or out_dir.name
166
+ print(
167
+ f"{RED}Could not clear the static/ output directory.{RESET}\n"
168
+ f"{DIM} Still locked: {stuck}\n"
169
+ f" Something is holding a handle on it. Close whatever is using static/\n"
170
+ f" and retry -- a running `npm run static:serve`, a File Explorer window\n"
171
+ f" or terminal open inside static/, or an editor previewing an exported\n"
172
+ f" file are the usual causes.{RESET}"
173
+ )
174
+ return False
175
+
176
+
177
+ def _copy_assets() -> None:
178
+ for name in ASSET_DIRS:
179
+ src = PUBLIC_DIR / name
180
+ if src.is_dir():
181
+ dst = OUT_DIR / name
182
+ shutil.copytree(src, dst, dirs_exist_ok=True)
183
+ print(f"{DIM} copied public/{name}/ -> static/{name}/{RESET}")
184
+ for name in ASSET_FILES:
185
+ src = PUBLIC_DIR / name
186
+ if src.is_file():
187
+ shutil.copy2(src, OUT_DIR / name)
188
+ print(f"{DIM} copied public/{name} -> static/{name}{RESET}")
189
+
190
+
191
+ def build() -> int:
192
+ idx = get_files_index()
193
+
194
+ if not _reset_out_dir(OUT_DIR):
195
+ return 1
196
+
197
+ exported: list[str] = []
198
+ skipped: list[tuple[str, str]] = []
199
+ rpc_warnings: list[str] = []
200
+
201
+ print(f"\n{GREEN}Caspian static export{RESET} -> {OUT_DIR}\n")
202
+
203
+ async def export_one(client, url: str) -> None:
204
+ resp = await client.get(url, follow_redirects=False)
205
+
206
+ if resp.status_code in (301, 302, 303, 307, 308):
207
+ target = resp.headers.get("location", "?")
208
+ skipped.append(
209
+ (url, f"redirects to {target} -- likely auth-gated, needs the server")
210
+ )
211
+ return
212
+
213
+ if resp.status_code != 200:
214
+ skipped.append((url, f"returned HTTP {resp.status_code}"))
215
+ return
216
+
217
+ if "text/html" not in resp.headers.get("content-type", ""):
218
+ skipped.append((url, "non-HTML response"))
219
+ return
220
+
221
+ html = resp.text
222
+ out_file = _out_path_for(url)
223
+ out_file.parent.mkdir(parents=True, exist_ok=True)
224
+ out_file.write_text(html, encoding="utf-8")
225
+ exported.append(url)
226
+
227
+ # Heuristic: flag pages whose interactivity depends on the backend.
228
+ if "pp.rpc" in html or "X-PP-RPC" in html or "pp-rpc" in html:
229
+ rpc_warnings.append(url)
230
+
231
+ async def render_all() -> None:
232
+ # Drive the ASGI app in-process with httpx2 (same as the test suite).
233
+ # ASGITransport does not run lifespan on its own, so enter the app's real
234
+ # lifespan context to mirror a live server's startup/shutdown state.
235
+ transport = httpx2.ASGITransport(app=main.app)
236
+ async with main.app.router.lifespan_context(main.app):
237
+ async with httpx2.AsyncClient(
238
+ transport=transport, base_url="http://testserver"
239
+ ) as client:
240
+ for route in idx.routes:
241
+ url = route.url_path
242
+
243
+ # Dynamic segments: pre-render only what the route declares via
244
+ # static_paths() (the getStaticPaths equivalent). Otherwise skip.
245
+ if "{" in route.fastapi_rule:
246
+ param_sets = await _resolve_static_paths(route)
247
+ if not param_sets:
248
+ skipped.append(
249
+ (url, "dynamic route -- add static_paths() to its index.py to pre-render (like getStaticPaths)")
250
+ )
251
+ continue
252
+ for params in param_sets:
253
+ await export_one(client, _fill_rule(route.fastapi_rule, params))
254
+ continue
255
+
256
+ await export_one(client, url)
257
+
258
+ asyncio.run(render_all())
259
+
260
+ _copy_assets()
261
+
262
+ # ---- Report ----
263
+ print(f"\n{GREEN}Exported {len(exported)} page(s):{RESET}")
264
+ for url in exported:
265
+ rel = _out_path_for(url).relative_to(ROOT)
266
+ print(f" {GREEN}OK{RESET} {url} -> {rel}")
267
+
268
+ if rpc_warnings:
269
+ print(f"\n{YELLOW}Note: these pages appear to use pp.rpc()/server actions{RESET}")
270
+ print(f"{DIM} (they render, but those interactions are dead without the backend):{RESET}")
271
+ for url in rpc_warnings:
272
+ print(f" {YELLOW}!{RESET} {url}")
273
+
274
+ if skipped:
275
+ print(f"\n{YELLOW}Skipped {len(skipped)} route(s):{RESET}")
276
+ for url, reason in skipped:
277
+ print(f" {YELLOW}SKIP{RESET} {url} ({reason})")
278
+
279
+ print(
280
+ f"\n{GREEN}Done.{RESET} Preview it over HTTP: {GREEN}npm run static:serve{RESET} "
281
+ f"-> http://localhost:8000\n"
282
+ f"{DIM} Do NOT double-click static/index.html (file://): root-absolute asset\n"
283
+ f" paths break and browsers block ES module scripts from file:// origins.\n"
284
+ f" Deploy the static/ folder to any HTTP host (Netlify, Vercel, GitHub Pages, nginx).{RESET}\n"
285
+ )
286
+ return 0
287
+
288
+
289
+ if __name__ == "__main__":
290
+ raise SystemExit(build())