create-caspian-app 1.6.4 → 1.8.0

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.
@@ -46,6 +46,8 @@ sys.path.insert(0, str(ROOT))
46
46
  # Keep the app in non-production mode: dev generates an ephemeral session secret,
47
47
  # so the build does not require production secrets to be present.
48
48
  os.environ.setdefault("APP_ENV", "development")
49
+ # Starting the app's lifespan for the export must not start the task scheduler.
50
+ os.environ["CASPIAN_STATIC_EXPORT"] = "1"
49
51
 
50
52
  import httpx2 # noqa: E402 (the app's HTTP client; drives the ASGI app in-process)
51
53
 
@@ -96,8 +98,12 @@ def _fill_rule(fastapi_rule: str, params) -> str:
96
98
  params = {names[0]: params} if names else {}
97
99
  url = fastapi_rule
98
100
  for key, value in params.items():
101
+ if value is None or value == "":
102
+ # An optional catch-all (`[[...slug]]`) exports its base URL.
103
+ url = url.replace(f"/{{{key}:path}}", "")
104
+ continue
99
105
  url = url.replace(f"{{{key}:path}}", str(value)).replace(f"{{{key}}}", str(value))
100
- return url
106
+ return url or "/"
101
107
 
102
108
 
103
109
  def _out_path_for(url_path: str) -> Path:
@@ -242,6 +248,19 @@ def build() -> int:
242
248
 
243
249
  await export_one(client, url)
244
250
 
251
+ # sitemap.py / robots.py -> static/sitemap.xml, static/robots.txt
252
+ for url in getattr(main, "SEO_ROUTES", []):
253
+ resp = await client.get(url)
254
+ if resp.status_code != 200:
255
+ skipped.append((url, f"returned HTTP {resp.status_code}"))
256
+ continue
257
+ out_file = OUT_DIR / url.lstrip("/")
258
+ out_file.write_text(resp.text, encoding="utf-8")
259
+ exported.append(url)
260
+ if not os.getenv("APP_BASE_URL"):
261
+ seo_warnings.append(url)
262
+
263
+ seo_warnings: list[str] = []
245
264
  asyncio.run(render_all())
246
265
 
247
266
  _copy_assets()
@@ -249,9 +268,20 @@ def build() -> int:
249
268
  # ---- Report ----
250
269
  print(f"\n{GREEN}Exported {len(exported)} page(s):{RESET}")
251
270
  for url in exported:
252
- rel = _out_path_for(url).relative_to(ROOT)
271
+ target = (
272
+ OUT_DIR / url.lstrip("/")
273
+ if url in getattr(main, "SEO_ROUTES", [])
274
+ else _out_path_for(url)
275
+ )
276
+ rel = target.relative_to(ROOT)
253
277
  print(f" {GREEN}OK{RESET} {url} -> {rel}")
254
278
 
279
+ if seo_warnings:
280
+ print(
281
+ f"\n{YELLOW}Note: APP_BASE_URL is not set{RESET}{DIM} -- {', '.join(seo_warnings)} "
282
+ "contain http://testserver URLs. Set APP_BASE_URL to the public origin and rebuild.{RESET}"
283
+ )
284
+
255
285
  if rpc_warnings:
256
286
  print(f"\n{YELLOW}Note: these pages appear to use pp.rpc()/server actions{RESET}")
257
287
  print(f"{DIM} (they render, but those interactions are dead without the backend):{RESET}")
@@ -24,9 +24,9 @@ import argparse
24
24
  import itertools
25
25
  import json
26
26
  import os
27
- import re
28
27
  import subprocess
29
28
  import sys
29
+ import tempfile
30
30
  import threading
31
31
  import time
32
32
  from dataclasses import dataclass, field
@@ -277,51 +277,95 @@ def run_node_tests() -> Result:
277
277
  once per edit. A silent regression there is invisible from a green gate and
278
278
  costs a restart storm on the next feature branch.
279
279
 
280
- Invoked as `node --import tsx`, not `npx tsx`: `npx` resolves to a `.cmd`
281
- shim on Windows that `subprocess` cannot exec from a list argv, while `node`
282
- is a real executable on every platform the gate runs on.
280
+ The tests are authored against Vitest (`vi.useFakeTimers`, `describe`/`it`
281
+ from `vitest`), so they must run under Vitest -- `node --test` cannot load
282
+ them and fails every file before a single assertion runs.
283
+
284
+ Invoked as `node node_modules/vitest/vitest.mjs`, not `npx vitest`: `npx`
285
+ resolves to a `.cmd` shim on Windows that `subprocess` cannot exec from a
286
+ list argv, while `node` is a real executable on every platform the gate
287
+ runs on. Failure locations come from Vitest's JSON reporter, written beside
288
+ the live default reporter, rather than from scraping human-readable output.
283
289
  """
284
290
  tests = sorted(
285
- str(p.relative_to(PROJECT_ROOT)) for p in PROJECT_ROOT.glob("settings/*.test.ts")
291
+ p.relative_to(PROJECT_ROOT).as_posix() for p in PROJECT_ROOT.glob("settings/*.test.ts")
286
292
  )
287
293
  if not tests:
288
294
  return Result("node", ok=True, note="no TypeScript tests found")
289
295
 
290
- proc = _run_streamed(["node", "--import", "tsx", "--test", *tests])
291
- ok = proc.returncode == 0
292
-
293
- issues: list[Issue] = []
294
- if not ok:
295
- # The spec reporter ends with a `failing tests:` block that pairs a
296
- # `test at path:line:col` line with the failing test's name.
297
- pending: tuple[str, int, int] | None = None
298
- for raw in (proc.stdout + proc.stderr).splitlines():
299
- line = raw.strip()
300
- location = re.match(r"^test at (.+):(\d+):(\d+)$", line)
301
- if location:
302
- pending = (location.group(1), int(location.group(2)), int(location.group(3)))
303
- continue
304
- if pending and line.startswith("✖"):
305
- name = re.sub(r"\s*\([\d.]+ms\)$", "", line[1:].strip())
306
- path, line_no, column = pending
307
- issues.append(
308
- Issue(
309
- path=path.replace("\\", "/"),
310
- line=line_no,
311
- column=column,
312
- tool="node",
313
- code="test",
314
- message=name or "test failed",
315
- )
316
- )
317
- pending = None
296
+ vitest = PROJECT_ROOT / "node_modules" / "vitest" / "vitest.mjs"
297
+ if not vitest.is_file():
298
+ return Result("node", ok=False, note="vitest is not installed; run `npm install`")
299
+
300
+ with tempfile.TemporaryDirectory(prefix="pp-vitest-") as tmp:
301
+ report_path = Path(tmp) / "report.json"
302
+ proc = _run_streamed(
303
+ [
304
+ "node",
305
+ str(vitest),
306
+ "run",
307
+ "--includeTaskLocation",
308
+ "--reporter=default",
309
+ "--reporter=json",
310
+ f"--outputFile.json={report_path}",
311
+ *tests,
312
+ ]
313
+ )
314
+ ok = proc.returncode == 0
315
+ issues = _vitest_issues(report_path) if not ok else []
318
316
 
319
317
  note = ""
320
318
  if not ok and not issues:
321
- note = "node --test failed"
319
+ note = "vitest failed"
322
320
  return Result("node", ok=ok, issues=issues, note=note)
323
321
 
324
322
 
323
+ def _vitest_issues(report_path: Path) -> list[Issue]:
324
+ """Turn Vitest's JSON report into one issue per failing test or file."""
325
+ try:
326
+ report = json.loads(report_path.read_text(encoding="utf-8"))
327
+ except OSError, ValueError:
328
+ return []
329
+
330
+ issues: list[Issue] = []
331
+ for file_result in report.get("testResults", []):
332
+ raw_path = str(file_result.get("name", ""))
333
+ try:
334
+ path = Path(raw_path).resolve().relative_to(PROJECT_ROOT).as_posix()
335
+ except ValueError:
336
+ path = raw_path.replace("\\", "/")
337
+
338
+ failed = [a for a in file_result.get("assertionResults", []) if a.get("status") == "failed"]
339
+ for assertion in failed:
340
+ location = assertion.get("location") or {}
341
+ issues.append(
342
+ Issue(
343
+ path=path,
344
+ line=int(location.get("line", 1)),
345
+ column=int(location.get("column", 1)),
346
+ tool="node",
347
+ code="test",
348
+ message=assertion.get("fullName") or "test failed",
349
+ )
350
+ )
351
+
352
+ # A file that fails to load (bad import, syntax error) has no failing
353
+ # assertions, only a file-level message.
354
+ if not failed and file_result.get("status") == "failed":
355
+ message = str(file_result.get("message") or "test file failed").strip()
356
+ issues.append(
357
+ Issue(
358
+ path=path,
359
+ line=1,
360
+ column=1,
361
+ tool="node",
362
+ code="test",
363
+ message=message.splitlines()[0] if message else "test file failed",
364
+ )
365
+ )
366
+ return issues
367
+
368
+
325
369
  def run_pytest() -> Result:
326
370
  # `-o addopts=` drops the ini `-q` so `-v` can print one live line per test
327
371
  # (the "which test is running" progress); `-rfE` keeps the `FAILED nodeid -
@@ -1,95 +1,94 @@
1
- from typing import Optional
2
- from casp.component_decorator import html
3
- from casp.layout import Metadata
4
-
5
-
6
- metadata = Metadata(
7
- title="Application Error",
8
- description="An unexpected error occurred.",
9
- extra={"robots": "noindex, nofollow"},
10
- )
11
-
12
-
13
- def page(error_message: str, error_trace: Optional[str] = None):
14
- return html(r"""
15
- <main class="container py-10">
16
- <div class="mx-auto max-w-2xl rounded bg-white p-6 shadow-sm">
17
- <div class="flex items-start gap-4">
18
- <svg class="h-10 w-10 text-red-600"
19
- viewBox="0 0 24 24"
20
- fill="none"
21
- xmlns="http://www.w3.org/2000/svg"
22
- aria-hidden="true">
23
- <path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
24
- <path d="M12 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
25
- <path d="M12 16h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
26
- </svg>
27
-
28
- <div class="flex-1">
29
- <h1 class="text-2xl font-semibold text-red-600">Something went wrong</h1>
30
- <p class="mt-2 text-sm text-gray-600">
31
- We're sorry—an unexpected error occurred. Try reloading or return to
32
- the homepage.
33
- </p>
34
- <p class="mt-3 text-sm text-gray-700">{{ error_message }}</p>
35
-
36
- <div class="mt-4 flex flex-wrap gap-2">
37
- <a href="/"
38
- class="inline-block rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800 hover:bg-gray-200">
39
- Home
40
- </a>
41
- <button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
42
- onclick="window.location.reload()">Reload</button>
43
- {% if error_trace %}
44
- <button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
45
- onclick="setShowTrace(current => !current)">{showTrace ? "Hide details" : "Show details"}</button>
46
- {% endif %}
47
- </div>
48
- </div>
49
- </div>
50
-
51
- {% if error_trace %}
52
- <section class="mt-4" hidden="{!showTrace}">
53
- <div class="mb-2 flex items-center justify-between">
54
- <div class="text-xs text-gray-500">Error details (development only)</div>
55
- <div class="flex gap-2">
56
- <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
57
- onclick="copyTrace()">{traceCopied ? "Copied" : "Copy"}</button>
58
- <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
59
- onclick="downloadTrace()">Download</button>
60
- </div>
61
- </div>
62
- <pre pp-ref="{tracePre}"
63
- class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{ error_trace }}</pre>
64
- </section>
65
- {% endif %}
66
-
67
- <script>
68
- const [showTrace, setShowTrace] = pp.state(false);
69
- const [traceCopied, setTraceCopied] = pp.state(false);
70
- const tracePre = pp.ref(null);
71
-
72
- async function copyTrace() {
73
- if (!tracePre.current) return;
74
- await navigator.clipboard.writeText(tracePre.current.innerText);
75
- setTraceCopied(true);
76
- setTimeout(() => setTraceCopied(false), 2000);
77
- }
78
-
79
- function downloadTrace() {
80
- if (!tracePre.current) return;
81
- const blob = new Blob([tracePre.current.innerText], { type: "text/plain" });
82
- const url = URL.createObjectURL(blob);
83
- const link = document.createElement("a");
84
- link.href = url;
85
- link.download = "error_trace.txt";
86
- link.click();
87
- URL.revokeObjectURL(url);
88
- }
89
- </script>
90
- </div>
91
- </main>
92
- """,
93
- error_message=error_message,
94
- error_trace=error_trace,
95
- )
1
+ from casp.component_decorator import html
2
+ from casp.errors import ErrorInfo
3
+ from casp.layout import Metadata
4
+
5
+
6
+ metadata = Metadata(
7
+ title="Application Error",
8
+ description="An unexpected error occurred.",
9
+ extra={"robots": "noindex, nofollow"},
10
+ )
11
+
12
+
13
+ def page(error: ErrorInfo):
14
+ return html(r"""
15
+ <main class="container py-10">
16
+ <div class="mx-auto max-w-2xl rounded bg-white p-6 shadow-sm">
17
+ <div class="flex items-start gap-4">
18
+ <svg class="h-10 w-10 text-red-600"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ xmlns="http://www.w3.org/2000/svg"
22
+ aria-hidden="true">
23
+ <path d="M11.001 2a1 1 0 0 1 .998 0L21 7v9a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V7l8.001-5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
24
+ <path d="M12 8v4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
25
+ <path d="M12 16h.01" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
26
+ </svg>
27
+
28
+ <div class="flex-1">
29
+ <p class="text-xs font-medium uppercase tracking-wide text-gray-500">{{ error.status }} · {{ error.reason }}</p>
30
+ <h1 class="mt-1 text-2xl font-semibold text-red-600">Something went wrong</h1>
31
+ <p class="mt-3 text-sm text-gray-700">{{ error.message }}</p>
32
+ {% if error.request_id %}
33
+ <p class="mt-2 text-xs text-gray-500">Reference: {{ error.request_id }}</p>
34
+ {% endif %}
35
+
36
+ <div class="mt-4 flex flex-wrap gap-2">
37
+ <a href="/"
38
+ class="inline-block rounded border border-gray-200 bg-gray-100 px-3 py-1.5 text-sm text-gray-800 hover:bg-gray-200">
39
+ Home
40
+ </a>
41
+ <button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
42
+ onclick="window.location.reload()">Reload</button>
43
+ {% if error.trace %}
44
+ <button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
45
+ onclick="setShowTrace(current => !current)">{showTrace ? "Hide details" : "Show details"}</button>
46
+ {% endif %}
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+ {% if error.trace %}
52
+ <section class="mt-4" hidden="{!showTrace}">
53
+ <div class="mb-2 flex items-center justify-between">
54
+ <div class="text-xs text-gray-500">Error details (development only)</div>
55
+ <div class="flex gap-2">
56
+ <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
57
+ onclick="copyTrace()">{traceCopied ? "Copied" : "Copy"}</button>
58
+ <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
59
+ onclick="downloadTrace()">Download</button>
60
+ </div>
61
+ </div>
62
+ <pre pp-ref="{tracePre}"
63
+ class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{ error.trace }}</pre>
64
+ </section>
65
+ {% endif %}
66
+
67
+ <script>
68
+ const [showTrace, setShowTrace] = pp.state(false);
69
+ const [traceCopied, setTraceCopied] = pp.state(false);
70
+ const tracePre = pp.ref(null);
71
+
72
+ async function copyTrace() {
73
+ if (!tracePre.current) return;
74
+ await navigator.clipboard.writeText(tracePre.current.innerText);
75
+ setTraceCopied(true);
76
+ setTimeout(() => setTraceCopied(false), 2000);
77
+ }
78
+
79
+ function downloadTrace() {
80
+ if (!tracePre.current) return;
81
+ const blob = new Blob([tracePre.current.innerText], { type: "text/plain" });
82
+ const url = URL.createObjectURL(blob);
83
+ const link = document.createElement("a");
84
+ link.href = url;
85
+ link.download = "error_trace.txt";
86
+ link.click();
87
+ URL.revokeObjectURL(url);
88
+ }
89
+ </script>
90
+ </div>
91
+ </main>
92
+ """,
93
+ error=error,
94
+ )
@@ -1,37 +1,33 @@
1
1
  from casp.component_decorator import html
2
+ from casp.layout import Metadata
3
+
4
+ # Root defaults, inherited by every page unless overridden. A page title can be
5
+ # formatted for the whole app with `title_template="%s | My App"`.
6
+ metadata = Metadata(description="Powered by Caspian Framework")
2
7
 
3
8
 
4
9
  def layout():
5
10
  return html(r"""
6
11
  <!DOCTYPE html>
7
- <html lang="en">
12
+ <html lang="{{ current_locale() }}">
8
13
 
9
14
  <head>
10
15
  <meta charset="UTF-8" />
11
16
  <meta name="viewport"
12
17
  content="width=device-width, initial-scale=1.0" />
13
18
  <title>{{ metadata.title | default("Caspian App") }}</title>
14
- <meta name="description"
15
- content="{{ metadata.description | default('Powered by Caspian Framework') }}" />
16
19
  {#
17
- Everything a page put in `Metadata(extra={...})` -- robots, canonical,
18
- Open Graph, Twitter cards. The page pipeline already merges `extra` into
19
- this dict; without this loop it collected those values and dropped them,
20
- so `extra` silently did nothing. Open Graph is keyed by `property`, every
21
- other vocabulary by `name`.
20
+ Description, canonical, robots, keywords, Open Graph, Twitter, and every
21
+ `Metadata(extra={...})` entry. Each tag carries `data-pp-meta` so SPA
22
+ navigation replaces the set for the next page.
22
23
  #}
23
- {% for meta_name, meta_content in (metadata | default({})).items()
24
- if meta_name not in ("title", "description") and meta_content %}
25
- {% if meta_name.startswith("og:") %}
26
- <meta property="{{ meta_name }}" content="{{ meta_content }}" />
27
- {% else %}
28
- <meta name="{{ meta_name }}" content="{{ meta_content }}" />
29
- {% endif %}
30
- {% endfor %}
24
+ {{ metadata_tags(metadata | default({})) }}
31
25
  <link rel="icon"
32
26
  href="/favicon.ico"
33
27
  type="image/x-icon"
34
28
  sizes="16x16" />
29
+ <link href="/css/styles.css" rel="stylesheet" />
30
+ <script type="module" src="/js/main.js"></script>
35
31
  </head>
36
32
 
37
33
  <body style="
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-caspian-app",
3
- "version": "1.6.4",
3
+ "version": "1.8.0",
4
4
  "description": "Scaffold a new Caspian project (FastAPI-powered reactive Python framework).",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",