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 +6 -5
- package/dist/.github/copilot-instructions.md +1 -1
- package/dist/AGENTS.md +1 -1
- package/dist/main.py +76 -27
- package/dist/src/app/error.py +113 -0
- package/dist/src/app/{index.html → index.py} +6 -0
- package/dist/src/app/layout.py +32 -0
- package/dist/src/app/{not-found.html → not_found.py} +32 -18
- package/dist/src/lib/websocket/sockets.py +740 -0
- package/package.json +1 -1
- package/dist/src/app/error.html +0 -111
- package/dist/src/app/layout.html +0 -26
- package/dist/src/lib/websocket/websocket_security.py +0 -192
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.
|
|
175
|
-
| `
|
|
176
|
-
| `error.
|
|
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.
|
|
624
|
-
│ │ ├──
|
|
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`
|
|
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`
|
|
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', '
|
|
1465
|
+
not_found_path = os.path.join('src', 'app', 'not_found.py')
|
|
1403
1466
|
if os.path.exists(not_found_path):
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
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.
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
</
|
|
17
|
-
|
|
18
|
-
|
|
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
|
+
""")
|