create-caspian-app 1.1.0 → 1.3.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.
package/README.md CHANGED
@@ -171,9 +171,9 @@ src/app/dashboard/layout.py -> wraps every /dashboard/* page
171
171
  | ---------------- | ------------------------------------------------------------------------------------------------------------- |
172
172
  | `index.py` | The page: `page()` returning `html(...)` markup, `metadata`, route-owned `@rpc()` actions, redirects, first-render data |
173
173
  | `layout.py` | The section wrapper: `layout()` returning the shell template (with `<slot />`) plus optional props and server logic |
174
- | `loading.html` | Navigation loading state for the route |
175
- | `not-found.html` | 404 UI |
176
- | `error.html` | Error UI |
174
+ | `loading.py` | Route-scoped navigation loader: synchronous `loading()` returning `html(...)` markup |
175
+ | `not_found.py` | Global 404 page: `page()` returning `html(...)` markup and metadata |
176
+ | `error.py` | Global 500 page: `page()` receiving safe error context and returning `html(...)` markup |
177
177
 
178
178
  `page()` returns `html(r"""...""", **context)`. It can also return a
179
179
  `(page_html, layout_props)` tuple, whose dict keys become `{{ layout.* }}` in a parent layout.
@@ -620,8 +620,9 @@ my-app/
620
620
  │ │ ├── layout.py # Root layout (template + props from layout())
621
621
  │ │ ├── index.py # Home page (markup + logic in one file)
622
622
  │ │ ├── globals.css
623
- │ │ ├── error.html
624
- │ │ ├── not-found.html
623
+ │ │ ├── error.py # Global 500 page
624
+ │ │ ├── not_found.py # Global 404 page
625
+ │ │ ├── dashboard/loading.py # /dashboard navigation loading UI
625
626
  │ │ └── users/[id]/
626
627
  │ │ └── index.py # /users/:id
627
628
  │ ├── components/ # Reusable UI (@component)
@@ -231,7 +231,7 @@ This is the top architectural requirement for this workspace. Treat it as a hard
231
231
 
232
232
  - These files are the packaged Caspian documentation layer, not the runtime and not the source of current workspace state.
233
233
  - Use them to help AI answer three questions: which Caspian feature applies, which project files should be inspected next, and which workflow is appropriate once the feature is confirmed as enabled.
234
- - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` when deciding what belongs in `index.py`, `layout.py`, `loading.html`, `not-found.html`, or `error.html`.
234
+ - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: navigation loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
235
235
  - Use `node_modules/caspian-utils/dist/docs/websockets.md` when deciding how to document or implement app-owned FastAPI WebSockets, browser `WebSocket` clients, origin checks, auth/session checks, message contracts, and the choice between WebSockets, RPC, and SSE.
236
236
  - Verify behavior claims in this order:
237
237
  1. `caspian.config.json`, then `main.py`, `src/lib/**`, `public/js/**`, `prisma/**`, `src/app/**`
package/dist/AGENTS.md CHANGED
@@ -70,7 +70,7 @@ Use `.github/copilot-instructions.md` for the repo-wide implementation rules. Th
70
70
  - Treat `public/` as a URL-root mapping, not a directory registry: an existing `public/icons/app.png` is served as `/icons/app.png` without a per-directory route, mount, or prefix list in `main.py`. `PublicFilesMiddleware` handles only `GET`/`HEAD`, resolves paths beneath `public/`, rejects traversal and symlink escape, and falls through when no file exists. Keep it inside `SecurityHeadersMiddleware` and outside rate limiting, body parsing, sessions, CSRF, auth, RPC, and page routing.
71
71
  - Use `node_modules/caspian-utils/dist/docs/pulsepoint-runtime-map.md` when a behavior is controlled by the shipped PulsePoint browser runtime and the task names state, effects, refs, context, portals, directives, `pp.rpc`, uploads, streaming, SPA navigation, or scroll restoration.
72
72
  - Use `node_modules/caspian-utils/dist/docs/websockets.md` when the task names WebSockets, live bidirectional channels, socket origin checks, socket auth/session behavior, broadcast managers, or native browser `WebSocket` clients.
73
- - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` when the task asks what belongs in `index.py`, `layout.py`, `loading.html`, `not-found.html`, or `error.html`.
73
+ - Use `node_modules/caspian-utils/dist/docs/file-conventions.md` for the general special-file model, then verify the completed Python migration in `main.py` and `.venv/Lib/site-packages/casp/**`: routes use `index.py`, layouts use `layout.py`, loading UI uses `loading.py`, and global fallback pages use `not_found.py` and `error.py`. This app has no authored `.html` special files.
74
74
  - When `caspian.config.json` has `prisma: true`, database reads and writes from Python routes, layouts, RPC actions, upload flows, auth flows, and helpers must use the generated Prisma Python ORM in `src/lib/prisma/**`. Do not create a separate database fetch layer with raw drivers, hand-written SQL helpers, JSON manifests, app-specific HTTP fetches, or browser-side data fetches to replace the ORM. Use raw SQL only as a narrow Prisma ORM fallback when the generated client cannot express a query clearly.
75
75
  - Treat `npx prisma db seed` as a delicate, potentially destructive operation. In this workspace, seed scripts may clear tables before inserting fresh records. Before running that command, an AI agent must propose the exact command, warn that it can delete or overwrite database data including production data if the datasource is wrong, confirm the datasource when practical, and wait for explicit user approval.
76
76
  - Component-first page composition is the highest-priority authoring rule for this workspace (see `.github/copilot-instructions.md`). Build pages as a short assembly of `x-*` chunk components (top menu, sidebar, header, content sections, cards, forms, footer) and keep each chunk's long markup inside its own focused single-file `html(...)` component, so the page template in `src/app/**/index.py` stays small instead of holding a wall of HTML. Plan the chunk breakdown before writing the route, not as a later cleanup pass.
package/dist/main.py CHANGED
@@ -48,7 +48,6 @@ from casp.auth import (
48
48
  from casp.rpc import register_rpc_routes, rpc_limiter
49
49
  from casp.layout import (
50
50
  render_with_nested_layouts,
51
- compile_template,
52
51
  _finalize_page_region,
53
52
  _runtime_injections,
54
53
  _runtime_metadata,
@@ -1396,25 +1395,83 @@ if mcp_app is not None:
1396
1395
  # ====
1397
1396
 
1398
1397
 
1398
+ async def _render_special_page(
1399
+ page_path: str,
1400
+ request: Request,
1401
+ default_metadata: dict[str, str],
1402
+ context_data: dict[str, Any],
1403
+ ) -> tuple[str, str]:
1404
+ """Render an app-level Python page through the normal page/layout pipeline."""
1405
+ _runtime_metadata.set(None)
1406
+ _runtime_injections.set({"head": [], "body": []})
1407
+
1408
+ module = load_route_module(page_path)
1409
+ if not hasattr(module, "page"):
1410
+ raise AttributeError(f"Missing 'def page():' in {page_path}")
1411
+
1412
+ signature = get_page_signature(page_path, module.page)
1413
+ accepts_kwargs = any(
1414
+ parameter.kind is inspect.Parameter.VAR_KEYWORD
1415
+ for parameter in signature.parameters.values()
1416
+ )
1417
+ call_context = {"request": request, **context_data}
1418
+ call_kwargs = {
1419
+ name: value
1420
+ for name, value in call_context.items()
1421
+ if accepts_kwargs or name in signature.parameters
1422
+ }
1423
+
1424
+ result = module.page(**call_kwargs)
1425
+ if inspect.isawaitable(result):
1426
+ result = await result
1427
+ if isinstance(result, Response):
1428
+ raise TypeError(f"Special page {page_path} must return markup, not a Response")
1429
+
1430
+ page_layout_props: dict[str, Any] = {}
1431
+ page_content = result
1432
+ if isinstance(result, tuple):
1433
+ page_content = result[0]
1434
+ if len(result) >= 2 and isinstance(result[1], dict):
1435
+ page_layout_props = result[1]
1436
+
1437
+ page_metadata = default_metadata.copy()
1438
+ for metadata_obj in (getattr(module, "metadata", None), _runtime_metadata.get()):
1439
+ if not metadata_obj:
1440
+ continue
1441
+ if metadata_obj.title:
1442
+ page_metadata["title"] = metadata_obj.title
1443
+ if metadata_obj.description:
1444
+ page_metadata["description"] = metadata_obj.description
1445
+ if metadata_obj.extra:
1446
+ page_metadata.update(metadata_obj.extra)
1447
+
1448
+ page_source = getattr(page_content, "source_path", page_path)
1449
+ html_output, root_layout_id = await render_with_nested_layouts(
1450
+ children=str(page_content),
1451
+ route_dir=os.path.dirname(page_path),
1452
+ page_metadata=page_metadata,
1453
+ page_layout_props=page_layout_props,
1454
+ context_data={**call_context, **page_layout_props},
1455
+ page_component_source=page_source,
1456
+ control_mode=True,
1457
+ component_compiler=transform_components,
1458
+ )
1459
+ return finalize_html(html_output), root_layout_id
1460
+
1461
+
1399
1462
  @app.exception_handler(StarletteHTTPException)
1400
1463
  async def custom_404_handler(request: Request, exc: StarletteHTTPException):
1401
1464
  if exc.status_code == 404:
1402
- not_found_path = os.path.join('src', 'app', 'not-found.html')
1465
+ not_found_path = os.path.join('src', 'app', 'not_found.py')
1403
1466
  if os.path.exists(not_found_path):
1404
- with open(not_found_path, 'r', encoding='utf-8') as f:
1405
- content = f.read()
1406
- html_output, root_layout_id = await render_with_nested_layouts(
1407
- children=content,
1408
- route_dir='src/app',
1409
- page_metadata={
1467
+ html_output, root_layout_id = await _render_special_page(
1468
+ page_path=not_found_path,
1469
+ request=request,
1470
+ default_metadata={
1410
1471
  'title': "Page Not Found",
1411
1472
  'description': "The page you are looking for does not exist."
1412
1473
  },
1413
- page_layout_props=None,
1414
- context_data={'request': request},
1415
- page_component_source=not_found_path,
1416
- control_mode=True,
1417
- transform_fn=finalize_html
1474
+ context_data={},
1418
1475
  )
1419
1476
  resp = HTMLResponse(content=html_output, status_code=404)
1420
1477
  resp.headers['X-PP-Root-Layout'] = root_layout_id
@@ -1429,33 +1486,25 @@ async def custom_general_exception_handler(request: Request, exc: Exception):
1429
1486
  error_message = _client_error_message(exc)
1430
1487
  error_trace = full_trace if not IS_PRODUCTION else None
1431
1488
 
1432
- error_page_path = os.path.join('src', 'app', 'error.html')
1489
+ error_page_path = os.path.join('src', 'app', 'error.py')
1433
1490
  if os.path.exists(error_page_path):
1434
- with open(error_page_path, 'r', encoding='utf-8') as f:
1435
- raw_content = f.read()
1436
1491
  context_data = {'request': request,
1437
1492
  'error_message': error_message, 'error_trace': error_trace}
1438
1493
  try:
1439
- rendered_content = compile_template(
1440
- raw_content).render(**context_data)
1441
- html_output, root_layout_id = await render_with_nested_layouts(
1442
- children=rendered_content,
1443
- route_dir='src/app',
1444
- page_metadata={
1494
+ html_output, root_layout_id = await _render_special_page(
1495
+ page_path=error_page_path,
1496
+ request=request,
1497
+ default_metadata={
1445
1498
  'title': 'Application Error',
1446
1499
  'description': 'An unexpected error occurred.'
1447
1500
  },
1448
- page_layout_props=None,
1449
1501
  context_data=context_data,
1450
- page_component_source=error_page_path,
1451
- control_mode=True,
1452
- transform_fn=finalize_html
1453
1502
  )
1454
1503
  resp = HTMLResponse(content=html_output, status_code=500)
1455
1504
  resp.headers['X-PP-Root-Layout'] = root_layout_id
1456
1505
  return resp
1457
1506
  except Exception as render_exc:
1458
- print("Error rendering error.html:", render_exc)
1507
+ print("Error rendering error.py:", render_exc)
1459
1508
  return HTMLResponse(
1460
1509
  content=f"<h1>500 - Internal Server Error</h1><p>{error_message}</p>",
1461
1510
  status_code=500
@@ -0,0 +1,113 @@
1
+ from typing import Optional
2
+
3
+ from casp.component_decorator import html
4
+ from casp.layout import Metadata
5
+
6
+
7
+ metadata = Metadata(
8
+ title="Application Error",
9
+ description="An unexpected error occurred.",
10
+ extra={"robots": "noindex, nofollow"},
11
+ )
12
+
13
+
14
+ def page(error_message: str, error_trace: Optional[str] = None):
15
+ return html(r"""
16
+ <main class="container py-10">
17
+ <div class="mx-auto max-w-2xl rounded bg-white p-6 shadow-sm">
18
+ <div class="flex items-start gap-4">
19
+ <svg class="h-10 w-10 text-red-600"
20
+ viewBox="0 0 24 24"
21
+ fill="none"
22
+ xmlns="http://www.w3.org/2000/svg"
23
+ aria-hidden="true">
24
+ <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"
25
+ stroke="currentColor"
26
+ stroke-width="1.5"
27
+ stroke-linecap="round"
28
+ stroke-linejoin="round" />
29
+ <path d="M12 8v4"
30
+ stroke="currentColor"
31
+ stroke-width="1.5"
32
+ stroke-linecap="round"
33
+ stroke-linejoin="round" />
34
+ <path d="M12 16h.01"
35
+ stroke="currentColor"
36
+ stroke-width="1.5"
37
+ stroke-linecap="round"
38
+ stroke-linejoin="round" />
39
+ </svg>
40
+
41
+ <div class="flex-1">
42
+ <h1 class="text-2xl font-semibold text-red-600">Something went wrong</h1>
43
+ <p class="mt-2 text-sm text-gray-600">
44
+ We're sorry—an unexpected error occurred. Try reloading or return to
45
+ the homepage.
46
+ </p>
47
+ <p class="mt-3 text-sm text-gray-700">{{ error_message }}</p>
48
+
49
+ <div class="mt-4 flex flex-wrap gap-2">
50
+ <a href="/"
51
+ 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">
52
+ Home
53
+ </a>
54
+ <button class="inline-block rounded bg-blue-600 px-3 py-1.5 text-sm text-white"
55
+ onclick="window.location.reload()">
56
+ Reload
57
+ </button>
58
+ {% if error_trace %}
59
+ <button class="inline-block rounded border border-gray-200 bg-transparent px-3 py-1.5 text-sm text-black"
60
+ onclick="setShowTrace(current => !current)">
61
+ {showTrace ? "Hide details" : "Show details"}
62
+ </button>
63
+ {% endif %}
64
+ </div>
65
+ </div>
66
+ </div>
67
+
68
+ {% if error_trace %}
69
+ <section class="mt-4" hidden="{!showTrace}">
70
+ <div class="mb-2 flex items-center justify-between">
71
+ <div class="text-xs text-gray-500">Error details (development only)</div>
72
+ <div class="flex gap-2">
73
+ <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
74
+ onclick="copyTrace()">
75
+ {traceCopied ? "Copied" : "Copy"}
76
+ </button>
77
+ <button class="rounded border bg-gray-100 px-2 py-1 text-xs text-black"
78
+ onclick="downloadTrace()">
79
+ Download
80
+ </button>
81
+ </div>
82
+ </div>
83
+ <pre pp-ref="{tracePre}"
84
+ class="overflow-auto rounded bg-gray-100 p-4 text-xs text-black">{{ error_trace }}</pre>
85
+ </section>
86
+ {% endif %}
87
+
88
+ <script>
89
+ const [showTrace, setShowTrace] = pp.state(false);
90
+ const [traceCopied, setTraceCopied] = pp.state(false);
91
+ const tracePre = pp.ref(null);
92
+
93
+ async function copyTrace() {
94
+ if (!tracePre.current) return;
95
+ await navigator.clipboard.writeText(tracePre.current.innerText);
96
+ setTraceCopied(true);
97
+ setTimeout(() => setTraceCopied(false), 2000);
98
+ }
99
+
100
+ function downloadTrace() {
101
+ if (!tracePre.current) return;
102
+ const blob = new Blob([tracePre.current.innerText], { type: "text/plain" });
103
+ const url = URL.createObjectURL(blob);
104
+ const link = document.createElement("a");
105
+ link.href = url;
106
+ link.download = "error_trace.txt";
107
+ link.click();
108
+ URL.revokeObjectURL(url);
109
+ }
110
+ </script>
111
+ </div>
112
+ </main>
113
+ """, error_message=error_message, error_trace=error_trace)
@@ -1,3 +1,8 @@
1
+ from casp.component_decorator import html
2
+
3
+
4
+ def page():
5
+ return html(r"""
1
6
  <div
2
7
  class="min-h-screen bg-background text-foreground selection:bg-primary/30 flex flex-col items-center justify-between py-8 md:py-12 px-6 md:px-24 overflow-x-hidden relative"
3
8
  >
@@ -157,3 +162,4 @@
157
162
  </a>
158
163
  </div>
159
164
  </div>
165
+ """)
@@ -0,0 +1,32 @@
1
+ def layout():
2
+ return r"""
3
+ <!DOCTYPE html>
4
+ <html lang="en">
5
+ <head>
6
+ <meta charset="UTF-8" />
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
8
+ <title>{{ metadata.title | default("Caspian App") }}</title>
9
+ <meta
10
+ name="description"
11
+ content="{{ metadata.description | default('Powered by Caspian Framework') }}"
12
+ />
13
+ {% if metadata.robots %}
14
+ <meta name="robots" content="{{ metadata.robots }}" />
15
+ {% endif %}
16
+ <link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="16x16" />
17
+ <link href="/css/styles.css" rel="stylesheet" />
18
+ <script type="module" src="/js/main.js"></script>
19
+ </head>
20
+
21
+ <body
22
+ style="
23
+ opacity: 0;
24
+ pointer-events: none;
25
+ user-select: none;
26
+ transition: opacity 0.18s ease-out;
27
+ "
28
+ >
29
+ <slot />
30
+ </body>
31
+ </html>
32
+ """
@@ -1,18 +1,32 @@
1
- <div class="bg-gray-50 flex h-screen items-center justify-center px-4">
2
- <div class="text-center">
3
- <h1 class="text-9xl font-black text-gray-200">404</h1>
4
-
5
- <p class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl">
6
- Uh-oh!
7
- </p>
8
-
9
- <p class="mt-4 text-gray-500">We can't find that page.</p>
10
-
11
- <a
12
- href="/"
13
- class="mt-6 inline-block rounded bg-indigo-600 px-5 py-3 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring"
14
- >
15
- Go Back Home
16
- </a>
17
- </div>
18
- </div>
1
+ from casp.component_decorator import html
2
+ from casp.layout import Metadata
3
+
4
+
5
+ metadata = Metadata(
6
+ title="Page Not Found",
7
+ description="The page you are looking for does not exist.",
8
+ extra={"robots": "noindex, nofollow"},
9
+ )
10
+
11
+
12
+ def page():
13
+ return html(r"""
14
+ <main class="flex min-h-screen items-center justify-center bg-gray-50 px-4">
15
+ <div class="text-center">
16
+ <h1 class="text-9xl font-black text-gray-200">404</h1>
17
+
18
+ <p class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl">
19
+ Uh-oh!
20
+ </p>
21
+
22
+ <p class="mt-4 text-gray-500">We can't find that page.</p>
23
+
24
+ <a
25
+ href="/"
26
+ class="mt-6 inline-block rounded bg-indigo-600 px-5 py-3 text-sm font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring"
27
+ >
28
+ Go Back Home
29
+ </a>
30
+ </div>
31
+ </main>
32
+ """)