@madojs/mado 0.5.0 → 0.6.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.
Files changed (122) hide show
  1. package/AGENTS.md +49 -1
  2. package/CHANGELOG.md +188 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +53 -14
  5. package/ROADMAP.md +36 -5
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +41 -7
  8. package/dist/src/forms.js +334 -59
  9. package/dist/src/forms.js.map +1 -1
  10. package/dist/src/html/bindings.d.ts +41 -0
  11. package/dist/src/html/bindings.js +163 -6
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html.d.ts +2 -0
  14. package/dist/src/html.js +1 -0
  15. package/dist/src/html.js.map +1 -1
  16. package/dist/src/index.d.ts +6 -6
  17. package/dist/src/index.js +2 -2
  18. package/dist/src/index.js.map +1 -1
  19. package/dist/src/page.d.ts +56 -0
  20. package/dist/src/page.js +17 -0
  21. package/dist/src/page.js.map +1 -1
  22. package/dist/src/router/manifest.d.ts +16 -1
  23. package/dist/src/router/manifest.js +181 -38
  24. package/dist/src/router/manifest.js.map +1 -1
  25. package/dist/src/router/match.d.ts +7 -2
  26. package/dist/src/router/match.js +14 -4
  27. package/dist/src/router/match.js.map +1 -1
  28. package/dist/src/router/navigation.d.ts +10 -0
  29. package/dist/src/router/navigation.js +73 -12
  30. package/dist/src/router/navigation.js.map +1 -1
  31. package/dist/src/signal.d.ts +15 -1
  32. package/dist/src/signal.js +112 -16
  33. package/dist/src/signal.js.map +1 -1
  34. package/docs/en/02-project-layout.md +99 -40
  35. package/docs/en/05-why-mado.md +1 -1
  36. package/docs/en/06-for-backenders.md +1 -1
  37. package/docs/en/07-llm-pitfalls.md +1 -1
  38. package/docs/en/09-shadow-vs-light-dom.md +60 -0
  39. package/docs/en/10-app-architecture.md +141 -0
  40. package/docs/en/11-layouts.md +115 -0
  41. package/docs/en/12-auth-and-api.md +217 -0
  42. package/docs/en/13-deployment.md +192 -0
  43. package/docs/en/14-testing.md +82 -0
  44. package/docs/en/15-error-handling.md +100 -0
  45. package/docs/en/16-bake-cookbook.md +93 -0
  46. package/docs/en/README.md +7 -0
  47. package/docs/fr/05-why-mado.md +1 -1
  48. package/docs/fr/06-for-backenders.md +1 -1
  49. package/docs/fr/07-llm-pitfalls.md +1 -1
  50. package/docs/fr/09-shadow-vs-light-dom.md +63 -0
  51. package/docs/fr/10-app-architecture.md +61 -0
  52. package/docs/fr/11-layouts.md +35 -0
  53. package/docs/fr/12-auth-and-api.md +35 -0
  54. package/docs/fr/13-deployment.md +39 -0
  55. package/docs/fr/14-testing.md +41 -0
  56. package/docs/fr/15-error-handling.md +50 -0
  57. package/docs/fr/16-bake-cookbook.md +35 -0
  58. package/docs/fr/README.md +7 -0
  59. package/docs/ru/05-why-mado.md +2 -2
  60. package/docs/ru/06-for-backenders.md +1 -1
  61. package/docs/ru/09-shadow-vs-light-dom.md +60 -0
  62. package/docs/ru/10-app-architecture.md +100 -0
  63. package/docs/ru/11-layouts.md +47 -0
  64. package/docs/ru/12-auth-and-api.md +53 -0
  65. package/docs/ru/13-deployment.md +60 -0
  66. package/docs/ru/14-testing.md +50 -0
  67. package/docs/ru/15-error-handling.md +56 -0
  68. package/docs/ru/16-bake-cookbook.md +55 -0
  69. package/docs/ru/README.md +7 -0
  70. package/docs/uk/06-for-backenders.md +2 -2
  71. package/docs/uk/09-shadow-vs-light-dom.md +91 -24
  72. package/docs/uk/10-app-architecture.md +56 -0
  73. package/docs/uk/11-layouts.md +34 -0
  74. package/docs/uk/12-auth-and-api.md +34 -0
  75. package/docs/uk/13-deployment.md +39 -0
  76. package/docs/uk/14-testing.md +34 -0
  77. package/docs/uk/15-error-handling.md +32 -0
  78. package/docs/uk/16-bake-cookbook.md +36 -0
  79. package/docs/uk/README.md +7 -0
  80. package/llms.txt +24 -1
  81. package/package.json +3 -1
  82. package/scripts/_config.mjs +224 -0
  83. package/scripts/bake.mjs +217 -120
  84. package/scripts/bundle.mjs +110 -67
  85. package/scripts/cli.mjs +127 -16
  86. package/scripts/preview.mjs +22 -12
  87. package/server/serve.mjs +101 -11
  88. package/starters/admin/README.md +63 -0
  89. package/starters/admin/index.html +21 -0
  90. package/starters/admin/mado.config.json +22 -0
  91. package/starters/admin/package.json +22 -0
  92. package/starters/admin/public/favicon.svg +4 -0
  93. package/starters/admin/src/components/x-button.ts +55 -0
  94. package/starters/admin/src/components/x-input.ts +74 -0
  95. package/starters/admin/src/layouts/app.ts +101 -0
  96. package/starters/admin/src/layouts/auth.ts +41 -0
  97. package/starters/admin/src/lib/api.ts +133 -0
  98. package/starters/admin/src/lib/auth.ts +83 -0
  99. package/starters/admin/src/main.ts +15 -0
  100. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  101. package/starters/admin/src/pages/admin/order-detail.ts +78 -0
  102. package/starters/admin/src/pages/admin/orders.ts +117 -0
  103. package/starters/admin/src/pages/home.ts +25 -0
  104. package/starters/admin/src/pages/login.ts +70 -0
  105. package/starters/admin/src/pages/not-found.ts +12 -0
  106. package/starters/admin/src/routes.ts +40 -0
  107. package/starters/admin/src/styles/global.ts +86 -0
  108. package/starters/admin/tsconfig.json +15 -0
  109. package/starters/crud/README.md +14 -2
  110. package/starters/crud/mado.config.json +20 -0
  111. package/starters/crud/package.json +9 -4
  112. package/starters/crud/src/components/app-shell.ts +13 -8
  113. package/starters/crud/src/main.ts +1 -4
  114. package/starters/crud/src/pages/ticket-detail.ts +1 -0
  115. package/starters/crud/src/pages/ticket-new.ts +1 -0
  116. package/starters/crud/src/pages/tickets.ts +1 -0
  117. package/starters/crud/src/routes.ts +4 -2
  118. package/starters/minimal/README.md +4 -2
  119. package/starters/minimal/mado.config.json +20 -0
  120. package/starters/minimal/package.json +8 -3
  121. package/starters/minimal/src/components/app-counter.ts +1 -1
  122. package/starters/minimal/src/routes.ts +4 -2
@@ -0,0 +1,133 @@
1
+ // Blessed API client recipe.
2
+ //
3
+ // One JSON fetch wrapper. One error type. Bearer token from `accessToken`.
4
+ // A refresh path that asks `/api/auth/refresh` (HttpOnly cookie) and retries
5
+ // once. Aborts via `AbortSignal`. JSON in / JSON out.
6
+ //
7
+ // Copy and adjust to your backend. Keep `api` as the single fetch boundary so
8
+ // that auth, refresh, error parsing and tracing live in exactly one place.
9
+
10
+ import { signal } from "@madojs/mado";
11
+
12
+ /** Memory-only access token. Refresh restores it from an HttpOnly cookie. */
13
+ export const accessToken = signal<string | null>(null);
14
+
15
+ /** Typed HTTP error with structured `body` for the UI to inspect. */
16
+ export class ApiError extends Error {
17
+ constructor(
18
+ public status: number,
19
+ public body: unknown,
20
+ message: string,
21
+ ) {
22
+ super(message);
23
+ this.name = "ApiError";
24
+ }
25
+ }
26
+
27
+ export interface ApiInit extends Omit<RequestInit, "body"> {
28
+ /** JSON-serialisable body. If set, `content-type: application/json` is added. */
29
+ json?: unknown;
30
+ /** Override the default base URL for this call. */
31
+ baseUrl?: string;
32
+ }
33
+
34
+ /**
35
+ * Join a base path and a relative path without losing prefixes.
36
+ * Works with both relative (`/api`) and absolute (`https://...`) bases.
37
+ */
38
+ function joinUrl(base: string, path: string): string {
39
+ if (/^https?:\/\//.test(path)) return path;
40
+
41
+ const p = path.replace(/^\/+/, "");
42
+
43
+ // Absolute URL base — keep its pathname prefix (for example /api).
44
+ if (/^https?:\/\//.test(base)) {
45
+ return new URL(p, base.replace(/\/+$/, "") + "/").href;
46
+ }
47
+
48
+ // Relative base (e.g. "/api") — simple string join, normalising slashes.
49
+ const b = base.replace(/\/+$/, "");
50
+ return p ? `${b}/${p}` : b || "/";
51
+ }
52
+
53
+ /**
54
+ * Create an API client bound to a base URL. Returns an `api()` function that
55
+ * speaks JSON, attaches the access token, and retries once after a 401 if a
56
+ * refresh succeeds.
57
+ *
58
+ * export const api = createApiClient("/api");
59
+ * const user = await api<User>("/users/me");
60
+ * await api("/posts", { method: "POST", json: { title: "hi" } });
61
+ */
62
+ export function createApiClient(baseUrl: string) {
63
+ let refreshing: Promise<boolean> | null = null;
64
+
65
+ async function refresh(): Promise<boolean> {
66
+ if (refreshing) return refreshing;
67
+ refreshing = (async () => {
68
+ try {
69
+ const res = await fetch(joinUrl(baseUrl, "/auth/refresh"), {
70
+ method: "POST",
71
+ credentials: "include",
72
+ });
73
+ if (!res.ok) return false;
74
+ const data = (await res.json().catch(() => null)) as
75
+ | { accessToken?: string }
76
+ | null;
77
+ if (!data?.accessToken) return false;
78
+ accessToken.set(data.accessToken);
79
+ return true;
80
+ } catch {
81
+ return false;
82
+ } finally {
83
+ refreshing = null;
84
+ }
85
+ })();
86
+ return refreshing;
87
+ }
88
+
89
+ async function request<T>(
90
+ path: string,
91
+ init: ApiInit = {},
92
+ retried = false,
93
+ ): Promise<T> {
94
+ const url = joinUrl(init.baseUrl ?? baseUrl, path);
95
+ const headers = new Headers(init.headers);
96
+ if (init.json !== undefined && !headers.has("content-type")) {
97
+ headers.set("content-type", "application/json");
98
+ }
99
+ const token = accessToken();
100
+ if (token) headers.set("authorization", `Bearer ${token}`);
101
+
102
+ const res = await fetch(url, {
103
+ ...init,
104
+ headers,
105
+ credentials: init.credentials ?? "include",
106
+ body:
107
+ init.json !== undefined
108
+ ? JSON.stringify(init.json)
109
+ : (init as RequestInit).body,
110
+ });
111
+
112
+ if (res.status === 401) {
113
+ if (!retried && (await refresh())) {
114
+ return request<T>(path, init, true);
115
+ }
116
+ accessToken.set(null);
117
+ throw new ApiError(401, null, "Unauthorized");
118
+ }
119
+ if (!res.ok) {
120
+ const body = await res.json().catch(() => null);
121
+ throw new ApiError(res.status, body, `HTTP ${res.status} ${res.statusText}`);
122
+ }
123
+ if (res.status === 204) return null as unknown as T;
124
+ return (await res.json()) as T;
125
+ }
126
+
127
+ return function api<T>(path: string, init: ApiInit = {}): Promise<T> {
128
+ return request<T>(path, init);
129
+ };
130
+ }
131
+
132
+ /** Default app-wide client. Change the base URL via mado.config.json dev.proxy. */
133
+ export const api = createApiClient("/api");
@@ -0,0 +1,83 @@
1
+ // Blessed auth recipe: memory-only access token + HttpOnly-cookie refresh +
2
+ // `requireAuth` guard for use in nested route groups.
3
+ //
4
+ // Usage in routes.ts:
5
+ //
6
+ // "/admin": layout({
7
+ // layout: () => import("./layouts/app.js"),
8
+ // guard: requireAuth,
9
+ // routes: { ... },
10
+ // })
11
+ //
12
+ // When a user lands on a protected route, requireAuth() tries to silently
13
+ // restore the access token via the refresh cookie. If that fails, it redirects
14
+ // to `/login?return=<original-url>`. The login page reads `return` and
15
+ // navigates back after a successful sign-in.
16
+
17
+ import type { Guard } from "@madojs/mado";
18
+ import { accessToken, api, ApiError } from "./api.js";
19
+
20
+ let restorePromise: Promise<boolean> | null = null;
21
+
22
+ /**
23
+ * Try once per session to restore the access token from the HttpOnly refresh
24
+ * cookie. Subsequent calls reuse the same promise so a hard refresh that hits
25
+ * five protected routes does not fire five refresh requests.
26
+ */
27
+ export async function restoreSession(): Promise<boolean> {
28
+ if (accessToken()) return true;
29
+ if (restorePromise) return restorePromise;
30
+ restorePromise = (async () => {
31
+ try {
32
+ const data = await api<{ accessToken: string }>("/auth/refresh", {
33
+ method: "POST",
34
+ });
35
+ accessToken.set(data.accessToken);
36
+ return true;
37
+ } catch (e) {
38
+ // 401 is expected for unauthenticated visitors.
39
+ if (e instanceof ApiError && e.status === 401) return false;
40
+ return false;
41
+ } finally {
42
+ restorePromise = null;
43
+ }
44
+ })();
45
+ return restorePromise;
46
+ }
47
+
48
+ /**
49
+ * Route guard: only let the user in if they have a valid session. Otherwise
50
+ * redirect to /login, preserving the original URL as `?return=`.
51
+ */
52
+ export const requireAuth: Guard = async ({ path }) => {
53
+ if (accessToken()) return;
54
+ if (await restoreSession()) return;
55
+ return {
56
+ redirect: `/login?return=${encodeURIComponent(path)}`,
57
+ replace: true,
58
+ };
59
+ };
60
+
61
+ export interface LoginCredentials {
62
+ email: string;
63
+ password: string;
64
+ }
65
+
66
+ /** Log in. Persists the access token in memory; refresh cookie is set by the server. */
67
+ export async function login(creds: LoginCredentials): Promise<void> {
68
+ const data = await api<{ accessToken: string }>("/auth/login", {
69
+ method: "POST",
70
+ json: creds,
71
+ });
72
+ accessToken.set(data.accessToken);
73
+ }
74
+
75
+ /** Log out everywhere: drop token in memory and tell the server to invalidate the refresh cookie. */
76
+ export async function logout(): Promise<void> {
77
+ try {
78
+ await api("/auth/logout", { method: "POST" });
79
+ } catch {
80
+ // Best-effort; even if the network is offline we still clear locally.
81
+ }
82
+ accessToken.set(null);
83
+ }
@@ -0,0 +1,15 @@
1
+ // App entry point.
2
+ //
3
+ // The single job of main.ts is: mount the router into #app. Everything else
4
+ // (layouts, guards, pages, auth) is declared in routes.ts and the modules it
5
+ // imports. Do NOT wrap routes in a custom shell here — the shell is a `layout()`
6
+ // (see src/layouts/) so it can be different per route group.
7
+
8
+ import { html, render } from "@madojs/mado";
9
+ import "./styles/global.js";
10
+ import router from "./routes.js";
11
+
12
+ const app = document.getElementById("app");
13
+ if (!app) throw new Error("#app not found");
14
+
15
+ render(html`${router.view}`, app);
@@ -0,0 +1,48 @@
1
+ // Admin dashboard. Demonstrates `resource()` for loading data with
2
+ // loading/error states.
3
+
4
+ import { html, jsonFetcher, page, resource } from "@madojs/mado";
5
+
6
+ interface Stats {
7
+ orders: number;
8
+ revenue: number;
9
+ customers: number;
10
+ }
11
+
12
+ export default page({
13
+ title: "Dashboard",
14
+ view: () => {
15
+ const stats = resource(() => "/api/admin/stats", jsonFetcher<Stats>(), {
16
+ staleTime: 30_000,
17
+ });
18
+
19
+ return html`
20
+ <h1 style="margin:0 0 24px;">Dashboard</h1>
21
+ ${() => {
22
+ if (stats.loading()) return html`<p class="muted">Loading…</p>`;
23
+ if (stats.error())
24
+ return html`<p style="color:var(--danger);">${stats.error()?.message}</p>`;
25
+ const s = stats.data();
26
+ if (!s) return null;
27
+ return html`
28
+ <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:var(--space-4);">
29
+ <div class="card">
30
+ <div class="muted">Orders</div>
31
+ <div style="font-size:24px;font-weight:700;">${s.orders}</div>
32
+ </div>
33
+ <div class="card">
34
+ <div class="muted">Revenue</div>
35
+ <div style="font-size:24px;font-weight:700;">
36
+ $${s.revenue.toLocaleString()}
37
+ </div>
38
+ </div>
39
+ <div class="card">
40
+ <div class="muted">Customers</div>
41
+ <div style="font-size:24px;font-weight:700;">${s.customers}</div>
42
+ </div>
43
+ </div>
44
+ `;
45
+ }}
46
+ `;
47
+ },
48
+ });
@@ -0,0 +1,78 @@
1
+ // Order detail page. Reads :id from params, fetches the order, and shows
2
+ // inline loading/error/empty/ready states.
3
+
4
+ import { html, jsonFetcher, page, resource } from "@madojs/mado";
5
+
6
+ interface OrderDetail {
7
+ id: string;
8
+ customer: string;
9
+ total: number;
10
+ status: string;
11
+ items: Array<{ sku: string; name: string; qty: number; price: number }>;
12
+ }
13
+
14
+ export default page<{ id: string }>({
15
+ title: ({ id }) => `Order ${id}`,
16
+ view: ({ params }) => {
17
+ const order = resource(
18
+ () => `/api/admin/orders/${params.id}`,
19
+ jsonFetcher<OrderDetail>(),
20
+ { staleTime: 15_000 },
21
+ );
22
+
23
+ return html`
24
+ <p>
25
+ <a href="/admin/orders" data-link>← All orders</a>
26
+ </p>
27
+ ${() => {
28
+ if (order.loading() && !order.data())
29
+ return html`<p class="muted">Loading order…</p>`;
30
+ if (order.error())
31
+ return html`<p style="color:var(--danger);">${order.error()?.message}</p>`;
32
+ const o = order.data();
33
+ if (!o)
34
+ return html`<p class="muted">Order not found.</p>`;
35
+ return html`
36
+ <h1 style="margin:0 0 8px;">Order ${o.id}</h1>
37
+ <p class="muted" style="margin:0 0 24px;">${o.customer} · ${o.status}</p>
38
+ <div class="card">
39
+ <table style="width:100%;border-collapse:collapse;">
40
+ <thead>
41
+ <tr style="text-align:left;border-bottom:1px solid var(--border);">
42
+ <th style="padding:8px 0;">SKU</th>
43
+ <th style="padding:8px 0;">Item</th>
44
+ <th style="padding:8px 0;text-align:right;">Qty</th>
45
+ <th style="padding:8px 0;text-align:right;">Price</th>
46
+ </tr>
47
+ </thead>
48
+ <tbody>
49
+ ${o.items.map(
50
+ (it) => html`
51
+ <tr style="border-bottom:1px solid var(--border);">
52
+ <td style="padding:8px 0;">${it.sku}</td>
53
+ <td style="padding:8px 0;">${it.name}</td>
54
+ <td style="padding:8px 0;text-align:right;">${it.qty}</td>
55
+ <td style="padding:8px 0;text-align:right;font-variant-numeric:tabular-nums;">
56
+ $${it.price.toFixed(2)}
57
+ </td>
58
+ </tr>
59
+ `,
60
+ )}
61
+ </tbody>
62
+ <tfoot>
63
+ <tr>
64
+ <td colspan="3" style="padding-top:12px;text-align:right;font-weight:600;">
65
+ Total
66
+ </td>
67
+ <td style="padding-top:12px;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;">
68
+ $${o.total.toFixed(2)}
69
+ </td>
70
+ </tr>
71
+ </tfoot>
72
+ </table>
73
+ </div>
74
+ `;
75
+ }}
76
+ `;
77
+ },
78
+ });
@@ -0,0 +1,117 @@
1
+ // Orders list. Demonstrates queryParam() filters and each() keyed table rows.
2
+
3
+ import {
4
+ each,
5
+ html,
6
+ jsonFetcher,
7
+ page,
8
+ queryParam,
9
+ resource,
10
+ } from "@madojs/mado";
11
+
12
+ interface Order {
13
+ id: string;
14
+ customer: string;
15
+ total: number;
16
+ status: "open" | "paid" | "shipped" | "cancelled";
17
+ }
18
+
19
+ export default page({
20
+ title: "Orders",
21
+ view: () => {
22
+ const status = queryParam("status", "");
23
+ const search = queryParam("q", "");
24
+
25
+ const orders = resource(
26
+ () => {
27
+ const params = new URLSearchParams();
28
+ if (status()) params.set("status", status());
29
+ if (search()) params.set("q", search());
30
+ const qs = params.toString();
31
+ return `/api/admin/orders${qs ? `?${qs}` : ""}`;
32
+ },
33
+ jsonFetcher<Order[]>(),
34
+ { staleTime: 5_000 },
35
+ );
36
+
37
+ return html`
38
+ <header style="display:flex;align-items:center;gap:var(--space-3);margin:0 0 16px;">
39
+ <h1 style="margin:0;">Orders</h1>
40
+ <span class="spacer"></span>
41
+ <input
42
+ type="search"
43
+ placeholder="Search…"
44
+ .value=${search}
45
+ @input=${(e: Event) => {
46
+ const t = e.target as HTMLInputElement;
47
+ search.set(t.value);
48
+ }}
49
+ style="padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);color:var(--fg);"
50
+ >
51
+ <select
52
+ .value=${status}
53
+ @change=${(e: Event) => {
54
+ const t = e.target as HTMLSelectElement;
55
+ status.set(t.value);
56
+ }}
57
+ style="padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);color:var(--fg);"
58
+ >
59
+ <option value="">All statuses</option>
60
+ <option value="open">Open</option>
61
+ <option value="paid">Paid</option>
62
+ <option value="shipped">Shipped</option>
63
+ <option value="cancelled">Cancelled</option>
64
+ </select>
65
+ </header>
66
+
67
+ <div class="card" style="padding:0;overflow:hidden;">
68
+ ${() => {
69
+ if (orders.loading() && !orders.data())
70
+ return html`<p class="muted" style="padding:16px;">Loading…</p>`;
71
+ if (orders.error())
72
+ return html`<p style="color:var(--danger);padding:16px;">
73
+ ${orders.error()?.message}
74
+ </p>`;
75
+ const list = orders.data() ?? [];
76
+ if (list.length === 0)
77
+ return html`<p class="muted" style="padding:24px;text-align:center;">
78
+ No orders match the current filters.
79
+ </p>`;
80
+ return html`
81
+ <table style="width:100%;border-collapse:collapse;">
82
+ <thead>
83
+ <tr style="text-align:left;border-bottom:1px solid var(--border);">
84
+ <th style="padding:10px 14px;">ID</th>
85
+ <th style="padding:10px 14px;">Customer</th>
86
+ <th style="padding:10px 14px;">Status</th>
87
+ <th style="padding:10px 14px;text-align:right;">Total</th>
88
+ </tr>
89
+ </thead>
90
+ <tbody>
91
+ ${() =>
92
+ each(
93
+ list,
94
+ (o) => o.id,
95
+ (o) => html`
96
+ <tr style="border-bottom:1px solid var(--border);">
97
+ <td style="padding:10px 14px;">
98
+ <a href="/admin/orders/${o.id}" data-link>${o.id}</a>
99
+ </td>
100
+ <td style="padding:10px 14px;">${o.customer}</td>
101
+ <td style="padding:10px 14px;">
102
+ <span class="muted">${o.status}</span>
103
+ </td>
104
+ <td style="padding:10px 14px;text-align:right;font-variant-numeric:tabular-nums;">
105
+ $${o.total.toFixed(2)}
106
+ </td>
107
+ </tr>
108
+ `,
109
+ )}
110
+ </tbody>
111
+ </table>
112
+ `;
113
+ }}
114
+ </div>
115
+ `;
116
+ },
117
+ });
@@ -0,0 +1,25 @@
1
+ // Public landing page. Demonstrates that the marketing surface can live
2
+ // alongside the admin app without a guard, and can be baked for SEO.
3
+
4
+ import { html, page } from "@madojs/mado";
5
+
6
+ export default page({
7
+ title: "Welcome",
8
+ head: () => ({
9
+ description: "An admin app scaffold built with Mado.",
10
+ og: {
11
+ title: "__APP_NAME__",
12
+ description: "An admin app scaffold built with Mado.",
13
+ type: "website",
14
+ },
15
+ }),
16
+ view: () => html`
17
+ <main style="max-width:720px;margin:0 auto;padding:64px 24px;">
18
+ <h1>__APP_NAME__</h1>
19
+ <p>This is the public landing page.</p>
20
+ <p>
21
+ <a href="/admin" data-link>Open the admin app →</a>
22
+ </p>
23
+ </main>
24
+ `,
25
+ });
@@ -0,0 +1,70 @@
1
+ // Login page. Reads `?return=` to bounce the user back where they were
2
+ // blocked by `requireAuth`.
3
+
4
+ import { html, navigate, page, queryParam, signal, useForm } from "@madojs/mado";
5
+ import { ApiError } from "../lib/api.js";
6
+ import { login } from "../lib/auth.js";
7
+ import "../components/x-input.js";
8
+ import "../components/x-button.js";
9
+
10
+ export default page({
11
+ title: "Sign in",
12
+ view: () => {
13
+ const returnTo = queryParam("return", "/admin");
14
+ const serverError = signal<string | null>(null);
15
+
16
+ const form = useForm({
17
+ email: { required: true, type: "email" as const },
18
+ password: { required: true, min: 4 },
19
+ });
20
+
21
+ const onSubmit = form.onSubmit(async (values) => {
22
+ serverError.set(null);
23
+ try {
24
+ await login({
25
+ email: String(values.email ?? ""),
26
+ password: String(values.password ?? ""),
27
+ });
28
+ navigate(returnTo(), { replace: true });
29
+ } catch (e) {
30
+ if (e instanceof ApiError && e.status === 401) {
31
+ serverError.set("Invalid email or password.");
32
+ } else {
33
+ serverError.set("Something went wrong. Try again.");
34
+ }
35
+ }
36
+ });
37
+
38
+ return html`
39
+ <h1 style="margin:0 0 16px;">Sign in</h1>
40
+ <p class="muted" style="margin:0 0 24px;">
41
+ Enter your credentials to continue.
42
+ </p>
43
+ <form @submit=${onSubmit} class="stack">
44
+ <x-input
45
+ label="Email"
46
+ name="email"
47
+ type="email"
48
+ required
49
+ @input=${form.onInput}
50
+ @blur=${form.onBlur}
51
+ ></x-input>
52
+ <x-input
53
+ label="Password"
54
+ name="password"
55
+ type="password"
56
+ required
57
+ @input=${form.onInput}
58
+ @blur=${form.onBlur}
59
+ ></x-input>
60
+ ${() =>
61
+ serverError()
62
+ ? html`<small style="color:var(--danger);">${serverError()}</small>`
63
+ : null}
64
+ <x-button
65
+ ?disabled=${() => !form.isValid() || form.submitting()}
66
+ >${() => (form.submitting() ? "Signing in…" : "Sign in")}</x-button>
67
+ </form>
68
+ `;
69
+ },
70
+ });
@@ -0,0 +1,12 @@
1
+ import { html, page } from "@madojs/mado";
2
+
3
+ export default page({
4
+ title: "Not found",
5
+ view: () => html`
6
+ <main style="max-width:560px;margin:0 auto;padding:80px 24px;text-align:center;">
7
+ <h1 style="margin:0 0 12px;">404</h1>
8
+ <p class="muted">This page does not exist.</p>
9
+ <p><a href="/" data-link>← Back home</a></p>
10
+ </main>
11
+ `,
12
+ });
@@ -0,0 +1,40 @@
1
+ // The blessed routes manifest for an admin app.
2
+ //
3
+ // "/" → public landing
4
+ // "/login" → centered auth layout
5
+ // "/admin/*" → admin shell with sidebar/topbar, guarded by requireAuth
6
+ // "*" → 404
7
+ //
8
+ // Layouts and guards live inside the layout() blocks. There is exactly one
9
+ // canonical place to put a shell: a layout() in this manifest. Do not wrap
10
+ // route output in main.ts or in custom-element wrappers — that path causes
11
+ // the "shell-below-content" bug described in the v1 plan.
12
+ //
13
+ // Bake: `manifest` is exported separately for `mado bake` to discover pages
14
+ // that declare `bake: { paths, data }`. Without this named export `mado bake`
15
+ // fails with a clear error.
16
+
17
+ import { layout, routes } from "@madojs/mado";
18
+ import { requireAuth } from "./lib/auth.js";
19
+
20
+ export const manifest = {
21
+ "/": () => import("./pages/home.js"),
22
+ "/login": layout({
23
+ layout: () => import("./layouts/auth.js"),
24
+ routes: {
25
+ "/": () => import("./pages/login.js"),
26
+ },
27
+ }),
28
+ "/admin": layout({
29
+ layout: () => import("./layouts/app.js"),
30
+ guard: requireAuth,
31
+ routes: {
32
+ "/": () => import("./pages/admin/dashboard.js"),
33
+ "/orders": () => import("./pages/admin/orders.js"),
34
+ "/orders/:id": () => import("./pages/admin/order-detail.js"),
35
+ },
36
+ }),
37
+ "*": () => import("./pages/not-found.js"),
38
+ };
39
+
40
+ export default routes(manifest);