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.
- package/dist/caspian.js +1 -1
- package/dist/index.js +1 -1
- package/dist/public/js/main.js +12 -12
- package/dist/settings/_component_imports.py +70 -70
- package/dist/settings/browser_log.py +498 -498
- package/dist/settings/bs-config.json +6 -6
- package/dist/settings/build-static.py +290 -290
- package/dist/settings/check.py +415 -415
- package/dist/settings/check_templates.py +274 -274
- package/dist/settings/component-map.ts +6 -6
- package/dist/settings/dev-log-bridge.ts +585 -585
- package/dist/settings/fix.py +95 -95
- package/dist/settings/python-server.ts +31 -31
- package/dist/settings/run-postcss.ts +317 -317
- package/dist/settings/serve-static.py +188 -188
- package/dist/tests/README.md +135 -135
- package/dist/tests/conftest.py +89 -89
- package/dist/tests/test_health_route.py +44 -44
- package/dist/tests/test_main_helpers.py +112 -112
- package/package.json +1 -1
|
@@ -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())
|