@madojs/mado 0.5.1 → 0.6.1

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 (107) hide show
  1. package/AGENTS.md +26 -0
  2. package/CHANGELOG.md +265 -0
  3. package/MADO_V1_PLAN.md +179 -0
  4. package/README.md +31 -13
  5. package/ROADMAP.md +28 -7
  6. package/TODO.md +72 -0
  7. package/dist/src/forms.d.ts +37 -4
  8. package/dist/src/forms.js +331 -57
  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/resource.js +11 -0
  23. package/dist/src/resource.js.map +1 -1
  24. package/dist/src/router/manifest.d.ts +16 -1
  25. package/dist/src/router/manifest.js +210 -40
  26. package/dist/src/router/manifest.js.map +1 -1
  27. package/dist/src/router/match.d.ts +7 -2
  28. package/dist/src/router/match.js +14 -4
  29. package/dist/src/router/match.js.map +1 -1
  30. package/dist/src/router/navigation.d.ts +10 -0
  31. package/dist/src/router/navigation.js +71 -3
  32. package/dist/src/router/navigation.js.map +1 -1
  33. package/dist/src/signal.d.ts +15 -1
  34. package/dist/src/signal.js +112 -16
  35. package/dist/src/signal.js.map +1 -1
  36. package/docs/en/02-project-layout.md +99 -40
  37. package/docs/en/10-app-architecture.md +141 -0
  38. package/docs/en/11-layouts.md +115 -0
  39. package/docs/en/12-auth-and-api.md +217 -0
  40. package/docs/en/13-deployment.md +192 -0
  41. package/docs/en/14-testing.md +82 -0
  42. package/docs/en/15-error-handling.md +100 -0
  43. package/docs/en/16-bake-cookbook.md +93 -0
  44. package/docs/en/README.md +7 -0
  45. package/docs/fr/10-app-architecture.md +61 -0
  46. package/docs/fr/11-layouts.md +35 -0
  47. package/docs/fr/12-auth-and-api.md +35 -0
  48. package/docs/fr/13-deployment.md +39 -0
  49. package/docs/fr/14-testing.md +41 -0
  50. package/docs/fr/15-error-handling.md +50 -0
  51. package/docs/fr/16-bake-cookbook.md +35 -0
  52. package/docs/fr/README.md +7 -0
  53. package/docs/ru/10-app-architecture.md +100 -0
  54. package/docs/ru/11-layouts.md +47 -0
  55. package/docs/ru/12-auth-and-api.md +53 -0
  56. package/docs/ru/13-deployment.md +60 -0
  57. package/docs/ru/14-testing.md +50 -0
  58. package/docs/ru/15-error-handling.md +56 -0
  59. package/docs/ru/16-bake-cookbook.md +55 -0
  60. package/docs/ru/README.md +7 -0
  61. package/docs/uk/10-app-architecture.md +56 -0
  62. package/docs/uk/11-layouts.md +34 -0
  63. package/docs/uk/12-auth-and-api.md +34 -0
  64. package/docs/uk/13-deployment.md +39 -0
  65. package/docs/uk/14-testing.md +34 -0
  66. package/docs/uk/15-error-handling.md +32 -0
  67. package/docs/uk/16-bake-cookbook.md +36 -0
  68. package/docs/uk/README.md +7 -0
  69. package/llms.txt +9 -1
  70. package/package.json +3 -1
  71. package/scripts/_config.mjs +224 -0
  72. package/scripts/bake.mjs +266 -121
  73. package/scripts/bundle.mjs +133 -67
  74. package/scripts/cli.mjs +195 -27
  75. package/scripts/preview.mjs +125 -21
  76. package/server/serve.mjs +161 -10
  77. package/starters/admin/README.md +63 -0
  78. package/starters/admin/index.html +28 -0
  79. package/starters/admin/mado.config.json +22 -0
  80. package/starters/admin/package.json +24 -0
  81. package/starters/admin/public/favicon.svg +4 -0
  82. package/starters/admin/src/components/x-button.ts +55 -0
  83. package/starters/admin/src/components/x-input.ts +74 -0
  84. package/starters/admin/src/layouts/app.ts +101 -0
  85. package/starters/admin/src/layouts/auth.ts +41 -0
  86. package/starters/admin/src/lib/api.ts +133 -0
  87. package/starters/admin/src/lib/auth.ts +83 -0
  88. package/starters/admin/src/main.ts +15 -0
  89. package/starters/admin/src/pages/admin/dashboard.ts +48 -0
  90. package/starters/admin/src/pages/admin/order-detail.ts +80 -0
  91. package/starters/admin/src/pages/admin/orders.ts +117 -0
  92. package/starters/admin/src/pages/home.ts +34 -0
  93. package/starters/admin/src/pages/login.ts +70 -0
  94. package/starters/admin/src/pages/not-found.ts +12 -0
  95. package/starters/admin/src/routes.ts +40 -0
  96. package/starters/admin/src/styles/global.ts +86 -0
  97. package/starters/admin/tsconfig.json +15 -0
  98. package/starters/crud/index.html +12 -4
  99. package/starters/crud/mado.config.json +20 -0
  100. package/starters/crud/package.json +9 -3
  101. package/starters/crud/src/pages/home.ts +16 -0
  102. package/starters/crud/src/routes.ts +4 -2
  103. package/starters/minimal/index.html +12 -4
  104. package/starters/minimal/mado.config.json +20 -0
  105. package/starters/minimal/package.json +9 -3
  106. package/starters/minimal/src/pages/home.ts +17 -0
  107. package/starters/minimal/src/routes.ts +4 -2
@@ -0,0 +1,115 @@
1
+ # Layouts
2
+
3
+ > **One blessed path.** Layouts in Mado are nested-route groups with a shared
4
+ > shell. There is exactly one canonical place to declare a layout — your
5
+ > `routes.ts` manifest. Putting layout code anywhere else (in `main.ts`, in a
6
+ > page view, in a global custom-element wrapper) is a bug pattern: the LLM and
7
+ > the human both produce visually broken UI when they guess differently.
8
+
9
+ ## The canonical recipe
10
+
11
+ ```ts
12
+ // src/routes.ts
13
+ import { layout, routes } from "@madojs/mado";
14
+ import { requireAuth } from "./lib/auth.js";
15
+
16
+ export const manifest = {
17
+ "/": () => import("./pages/home.js"), // no layout
18
+ "/login": layout({
19
+ layout: () => import("./layouts/auth.js"), // centered card
20
+ routes: { "/": () => import("./pages/login.js") },
21
+ }),
22
+ "/admin": layout({
23
+ layout: () => import("./layouts/app.js"), // admin shell
24
+ guard: requireAuth, // ← see 12-auth-and-api.md
25
+ routes: {
26
+ "/": () => import("./pages/admin/dashboard.js"),
27
+ "/orders": () => import("./pages/admin/orders.js"),
28
+ "/orders/:id": () => import("./pages/admin/order-detail.js"),
29
+ },
30
+ }),
31
+ "*": () => import("./pages/not-found.js"),
32
+ };
33
+
34
+ export default routes(manifest);
35
+ ```
36
+
37
+ A layout is just a `page({ view })` that renders `${ctx.child}` somewhere:
38
+
39
+ ```ts
40
+ // src/layouts/app.ts
41
+ import { html, page } from "@madojs/mado";
42
+ import "../components/app-shell.js"; // <x-app-shell> (sidebar + topbar + slot)
43
+
44
+ export default page({
45
+ view: ({ child }) => html`
46
+ <x-app-shell>${child}</x-app-shell>
47
+ `,
48
+ });
49
+ ```
50
+
51
+ That is the whole API.
52
+
53
+ - **Order of layouts** matters: outer groups wrap inner groups. The order in
54
+ the manifest is exactly the order of rendering.
55
+ - **One shell per group**, not one shell per page. If you want a different
56
+ shell for a subtree, create a new group with its own `layout`.
57
+ - **Layouts can be lazy** (`() => import(...)`). They are loaded together
58
+ with the page.
59
+
60
+ ## Why "one blessed path"
61
+
62
+ Without this convention, every page accumulates `<x-app-shell>${...}</x-app-shell>`
63
+ boilerplate, the LLM eventually puts the shell wrapper into `main.ts` "to make
64
+ it consistent", and the next refactor produces the classic
65
+ *"navigation appears below the page content"* screenshot. The nested-routes
66
+ recipe makes the shell the **outer frame** structurally; there is no way to
67
+ re-order it by accident.
68
+
69
+ ## Two acceptable alternatives (with caveats)
70
+
71
+ These exist for completeness. Reach for them only if you cannot use nested
72
+ routes.
73
+
74
+ ### a) A single shell with the router slot in `main.ts`
75
+
76
+ ```ts
77
+ import { html, render } from "@madojs/mado";
78
+ import "./components/app-shell.js";
79
+ import router from "./routes.js";
80
+
81
+ render(html`<x-app-shell>${router.view}</x-app-shell>`, app);
82
+ ```
83
+
84
+ Caveat: every route now lives inside one shell. You cannot have a centered
85
+ login page or a marketing landing page without the admin chrome around it.
86
+ Use this only for single-shell apps.
87
+
88
+ ### b) Per-page wrapping inside `view`
89
+
90
+ ```ts
91
+ export default page({
92
+ view: () => html`
93
+ <x-app-shell>
94
+ <h1>Orders</h1>
95
+ ...
96
+ </x-app-shell>
97
+ `,
98
+ });
99
+ ```
100
+
101
+ Caveat: repetition. Every new page must remember the wrapper. The first time
102
+ someone forgets it, the layout disappears and the LLM "fixes" it in the wrong
103
+ place. **Do not start with this.**
104
+
105
+ ## Where to find more
106
+
107
+ - `src/page.ts` defines `layout()`, `page()`, `Guard` and `NestedRoutes`.
108
+ - `src/router/manifest.ts` flattens the nested manifest and applies guards
109
+ outer → inner before the page renders.
110
+ - The `admin` starter (`mado init my-app --starter admin`) ships with three
111
+ groups (`/`, `/login`, `/admin`) and is the reference implementation.
112
+
113
+ If you ever feel tempted to invent a fourth pattern, write it down in your
114
+ project `docs/` first and discuss it with the team. The cost of inconsistency
115
+ in this exact spot is higher than the cost of a slightly awkward layout.
@@ -0,0 +1,217 @@
1
+ # Auth and API
2
+
3
+ > Mado has **zero runtime dependencies**, but that does not mean every team
4
+ > should reinvent its auth and HTTP boundary. This page is the blessed recipe.
5
+ > Copy it into your project, change the URLs and field names to match your
6
+ > backend, and stop touching it.
7
+
8
+ The `admin` starter (`mado init my-app --starter admin`) ships with these
9
+ files pre-installed in `src/lib/`:
10
+
11
+ - `api.ts` — `createApiClient(baseUrl)` + `accessToken` signal + `ApiError`
12
+ - `auth.ts` — `restoreSession()`, `login()`, `logout()`, `requireAuth` guard
13
+
14
+ The complete code is roughly 100 lines. Read it and own it.
15
+
16
+ ## Mental model
17
+
18
+ - One **API boundary** (`api()`): every fetch in your app goes through it.
19
+ - One **memory-only access token** (`accessToken` signal): never in
20
+ `localStorage`. Renewed silently from an HttpOnly refresh cookie when needed.
21
+ - One **route guard** (`requireAuth`): plug it into the layout block that
22
+ wraps protected routes. The guard runs before the page is rendered.
23
+
24
+ That is the entire surface.
25
+
26
+ ## `src/lib/api.ts`
27
+
28
+ ```ts
29
+ import { signal } from "@madojs/mado";
30
+
31
+ export const accessToken = signal<string | null>(null);
32
+
33
+ export class ApiError extends Error {
34
+ constructor(public status: number, public body: unknown, message: string) {
35
+ super(message);
36
+ this.name = "ApiError";
37
+ }
38
+ }
39
+
40
+ export interface ApiInit extends Omit<RequestInit, "body"> {
41
+ json?: unknown;
42
+ baseUrl?: string;
43
+ }
44
+
45
+ export function createApiClient(baseUrl: string) {
46
+ let refreshing: Promise<boolean> | null = null;
47
+
48
+ async function refresh(): Promise<boolean> {
49
+ if (refreshing) return refreshing;
50
+ refreshing = (async () => {
51
+ try {
52
+ const res = await fetch(new URL("/auth/refresh", baseUrl), {
53
+ method: "POST",
54
+ credentials: "include",
55
+ });
56
+ if (!res.ok) return false;
57
+ const data = (await res.json().catch(() => null)) as
58
+ | { accessToken?: string } | null;
59
+ if (!data?.accessToken) return false;
60
+ accessToken.set(data.accessToken);
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ } finally {
65
+ refreshing = null;
66
+ }
67
+ })();
68
+ return refreshing;
69
+ }
70
+
71
+ return async function api<T>(path: string, init: ApiInit = {}): Promise<T> {
72
+ const url = new URL(path, init.baseUrl ?? baseUrl);
73
+ const headers = new Headers(init.headers);
74
+ if (init.json !== undefined && !headers.has("content-type")) {
75
+ headers.set("content-type", "application/json");
76
+ }
77
+ const token = accessToken();
78
+ if (token) headers.set("authorization", `Bearer ${token}`);
79
+
80
+ const res = await fetch(url, {
81
+ ...init,
82
+ headers,
83
+ credentials: init.credentials ?? "include",
84
+ body: init.json !== undefined ? JSON.stringify(init.json) : (init as RequestInit).body,
85
+ });
86
+
87
+ if (res.status === 401) {
88
+ if (await refresh()) return api<T>(path, init);
89
+ accessToken.set(null);
90
+ throw new ApiError(401, null, "Unauthorized");
91
+ }
92
+ if (!res.ok) {
93
+ const body = await res.json().catch(() => null);
94
+ throw new ApiError(res.status, body, `HTTP ${res.status} ${res.statusText}`);
95
+ }
96
+ if (res.status === 204) return null as unknown as T;
97
+ return (await res.json()) as T;
98
+ };
99
+ }
100
+
101
+ export const api = createApiClient("/api");
102
+ ```
103
+
104
+ Key invariants:
105
+
106
+ - **Bearer token in memory only.** A page reload destroys it; `restoreSession()`
107
+ brings it back from the refresh cookie.
108
+ - **Refresh is single-flight.** Five resources hitting 401 at the same time
109
+ trigger exactly one refresh request.
110
+ - **Errors are typed.** Catch `ApiError` for `.status` and `.body`.
111
+ - **`credentials: include`** is the default, because the refresh cookie is
112
+ cross-host-safe only with `include`.
113
+
114
+ ## `src/lib/auth.ts`
115
+
116
+ ```ts
117
+ import type { Guard } from "@madojs/mado";
118
+ import { accessToken, api, ApiError } from "./api.js";
119
+
120
+ let restorePromise: Promise<boolean> | null = null;
121
+
122
+ export async function restoreSession(): Promise<boolean> {
123
+ if (accessToken()) return true;
124
+ if (restorePromise) return restorePromise;
125
+ restorePromise = (async () => {
126
+ try {
127
+ const data = await api<{ accessToken: string }>("/auth/refresh", {
128
+ method: "POST",
129
+ });
130
+ accessToken.set(data.accessToken);
131
+ return true;
132
+ } catch (e) {
133
+ if (e instanceof ApiError && e.status === 401) return false;
134
+ return false;
135
+ } finally {
136
+ restorePromise = null;
137
+ }
138
+ })();
139
+ return restorePromise;
140
+ }
141
+
142
+ export const requireAuth: Guard = async ({ path }) => {
143
+ if (accessToken()) return;
144
+ if (await restoreSession()) return;
145
+ return { redirect: `/login?return=${encodeURIComponent(path)}`, replace: true };
146
+ };
147
+
148
+ export async function login(creds: { email: string; password: string }) {
149
+ const data = await api<{ accessToken: string }>("/auth/login", {
150
+ method: "POST",
151
+ json: creds,
152
+ });
153
+ accessToken.set(data.accessToken);
154
+ }
155
+
156
+ export async function logout() {
157
+ try { await api("/auth/logout", { method: "POST" }); } catch {}
158
+ accessToken.set(null);
159
+ }
160
+ ```
161
+
162
+ Drop `requireAuth` into your manifest:
163
+
164
+ ```ts
165
+ "/admin": layout({
166
+ layout: () => import("./layouts/app.js"),
167
+ guard: requireAuth, // ← entire group is now protected
168
+ routes: { ... },
169
+ }),
170
+ ```
171
+
172
+ The guard runs *before* the page is rendered. If the user is not signed in
173
+ and the refresh cookie cannot revive the session, they are redirected to
174
+ `/login?return=<original>`. After a successful sign-in, the login page reads
175
+ `return` and navigates back.
176
+
177
+ ## Backend contract
178
+
179
+ The recipe assumes three endpoints. Adjust paths to taste:
180
+
181
+ | Endpoint | Request | Response (200) | Notes |
182
+ |-------------------------|---------------------------|------------------------------|--------------------------------|
183
+ | `POST /api/auth/login` | `{ email, password }` | `{ accessToken }` | Sets HttpOnly refresh cookie |
184
+ | `POST /api/auth/refresh`| (no body, cookie only) | `{ accessToken }` | Reads HttpOnly refresh cookie |
185
+ | `POST /api/auth/logout` | (no body) | `204` | Clears the refresh cookie |
186
+
187
+ If your backend uses a different shape (`{ token }`, `{ access_token, expires_in }`,
188
+ etc.), change `api.ts` and `auth.ts` in two places each. The rest of the app
189
+ keeps working.
190
+
191
+ ## Dev proxy
192
+
193
+ In development, point `/api/*` at your backend with `mado.config.json`:
194
+
195
+ ```jsonc
196
+ {
197
+ "dev": {
198
+ "port": 5173,
199
+ "proxy": { "/api": "http://localhost:3000" }
200
+ }
201
+ }
202
+ ```
203
+
204
+ The dev server forwards requests under `/api/*` to your backend, so both the
205
+ SPA and the API can be reached from the same origin — no CORS dance during
206
+ development.
207
+
208
+ ## When to deviate
209
+
210
+ - **SPA + cookies only** (no Bearer tokens). Remove the `authorization`
211
+ header and the `refresh()` retry; rely entirely on a session cookie.
212
+ - **Public site with optional auth.** Make `restoreSession()` opportunistic
213
+ on startup and skip the `requireAuth` guard.
214
+ - **Third-party API tokens** that cannot be refreshed. Drop `refresh()` and
215
+ fail loudly on 401.
216
+
217
+ Whatever you change, change it **in `api.ts` only**. Pages stay innocent.
@@ -0,0 +1,192 @@
1
+ # Deployment
2
+
3
+ > **One command. One artifact. Many hosts.** `mado release` writes everything
4
+ > a static host needs into `out/`. The same `out/` can be pushed to nginx,
5
+ > Cloudflare Pages, Netlify, S3 or GitHub Pages without re-building.
6
+
7
+ ## What `mado release` produces
8
+
9
+ ```
10
+ out/
11
+ ├── index.html ← SPA shell (loads the bundle + boots the router)
12
+ ├── assets/ ← hashed bundles (main-ABC.js, chunk-XYZ.js, …)
13
+ │ ├── *.gz ← precompressed gzip (gzip_static / Accept-Encoding)
14
+ │ └── *.br ← precompressed brotli (brotli_static / Accept-Encoding)
15
+ ├── baked/ ← prerendered SEO HTML (mado bake)
16
+ │ ├── <route>/index.html
17
+ │ └── sitemap.xml
18
+ ├── favicon.svg ← your public/ assets copied verbatim
19
+ ├── _redirects ← Cloudflare Pages / Netlify SPA fallback
20
+ └── _headers ← Cloudflare Pages / Netlify cache rules
21
+ ```
22
+
23
+ `_redirects` and `_headers` are generated automatically and only if they do
24
+ not already exist in your project. They are safely ignored by nginx and other
25
+ hosts.
26
+
27
+ ## Local rehearsal
28
+
29
+ ```bash
30
+ mado release
31
+ mado preview # http://localhost:4173 — serves out/ exactly as a static host would
32
+ ```
33
+
34
+ `mado preview` mirrors the behavior described below: it picks `.br` over
35
+ `.gz` over raw, prefers baked HTML at `/<route>/`, and falls back to
36
+ `index.html` for unknown paths.
37
+
38
+ ---
39
+
40
+ ## Recipe 1: VPS + nginx
41
+
42
+ The framework ships a production-ready [`nginx.conf`](../../nginx.conf) with
43
+ `gzip_static`, immutable cache for hashed bundles, and SPA fallback. Drop
44
+ `out/` into the host and point nginx at it.
45
+
46
+ ```bash
47
+ # Build the artifact locally
48
+ mado release
49
+
50
+ # Upload to the VPS
51
+ rsync -avz --delete out/ user@server:/var/www/myapp/
52
+
53
+ # On the VPS — first time only:
54
+ sudo cp /etc/nginx/conf.d/myapp.conf{,.bak}
55
+ sudo cp ./nginx.conf /etc/nginx/conf.d/myapp.conf
56
+ sudo nginx -t && sudo systemctl reload nginx
57
+ ```
58
+
59
+ Key lines of the shipped `nginx.conf`:
60
+
61
+ - `gzip_static on;` — serves the precompressed `.gz` files written by
62
+ `mado bundle`. Zero CPU at request time.
63
+ - `location ~* "^/(main|chunk|asset)-[A-Z0-9]+\.js$" { … immutable; }` —
64
+ hashed bundles get a one-year cache.
65
+ - `try_files $uri $uri/ /index.html;` — SPA fallback so deep links work
66
+ after a hard refresh.
67
+
68
+ Enable HTTPS with Let's Encrypt / Certbot. Add HSTS once you have it.
69
+
70
+ ---
71
+
72
+ ## Recipe 2: Cloudflare Pages
73
+
74
+ ```bash
75
+ mado release
76
+ npx wrangler pages deploy out --project-name=myapp
77
+ ```
78
+
79
+ - The generated `_redirects` (`/* /index.html 200`) gives you SPA fallback.
80
+ - The generated `_headers` (immutable cache for `/assets/*`, `no-cache` for
81
+ HTML) is honored by CF Pages.
82
+ - Baked routes (`out/baked/<route>/index.html`) take priority over the SPA
83
+ fallback because CF Pages matches static files first.
84
+
85
+ For preview branches, set the same build command in the CF Pages project:
86
+
87
+ ```
88
+ Build command: npm ci && npx mado release
89
+ Output directory: out
90
+ ```
91
+
92
+ There is also a small **edge prerender PoC** in
93
+ [`examples/cloudflare`](../../examples/cloudflare/) for catalogs too big to
94
+ bake at build time.
95
+
96
+ ---
97
+
98
+ ## Recipe 3: Static-only hosts (S3, Netlify, GitHub Pages)
99
+
100
+ Any static host works because `out/` is just files. Pick whichever you have:
101
+
102
+ **Netlify**
103
+ ```bash
104
+ mado release
105
+ npx netlify deploy --prod --dir=out
106
+ ```
107
+ `_redirects` and `_headers` are recognized natively.
108
+
109
+ **S3 / CloudFront**
110
+ ```bash
111
+ mado release
112
+ aws s3 sync out/ s3://my-bucket/ --delete \
113
+ --cache-control "public, max-age=31536000, immutable" --exclude '*.html'
114
+ aws s3 sync out/ s3://my-bucket/ \
115
+ --cache-control "no-cache, must-revalidate" --include '*.html'
116
+ ```
117
+ Configure CloudFront's "Default root object" to `index.html` and add a custom
118
+ error response: 403/404 → `/index.html` with status 200 (SPA fallback).
119
+
120
+ **GitHub Pages**
121
+ ```bash
122
+ mado release
123
+ # Push out/ into the gh-pages branch (or use actions/upload-pages-artifact)
124
+ ```
125
+ Pages handles `index.html` automatically. There is no native SPA fallback;
126
+ add a `404.html` that loads the SPA, or use the
127
+ [`spa-github-pages`](https://github.com/rafgraph/spa-github-pages) trick.
128
+
129
+ ---
130
+
131
+ ## Cache-control matrix
132
+
133
+ | Path | Cache-Control | Why |
134
+ |------------------------------|--------------------------------------------------|----------------------------------|
135
+ | `/assets/main-*.js` | `public, max-age=31536000, immutable` | hashed filename → never reuse |
136
+ | `/assets/chunk-*.js` | `public, max-age=31536000, immutable` | same |
137
+ | `/*.html` | `no-cache, must-revalidate` | always reflect latest deploy |
138
+ | Other static files | `public, max-age=86400` | safe daily cache |
139
+
140
+ `mado release` writes these rules into `out/_headers` for CF / Netlify and
141
+ the shipped `nginx.conf` enforces them server-side.
142
+
143
+ ---
144
+
145
+ ## CI sketch (GitHub Actions)
146
+
147
+ ```yaml
148
+ # .github/workflows/release.yml
149
+ name: release
150
+ on:
151
+ push:
152
+ branches: [main]
153
+ jobs:
154
+ release:
155
+ runs-on: ubuntu-latest
156
+ steps:
157
+ - uses: actions/checkout@v4
158
+ - uses: actions/setup-node@v4
159
+ with: { node-version: 22 }
160
+ - run: npm ci
161
+ - run: npx mado release
162
+ - uses: actions/upload-artifact@v4
163
+ with:
164
+ name: out
165
+ path: out
166
+ retention-days: 7
167
+ # Pick one deploy step:
168
+ # - run: rsync -avz out/ user@server:/var/www/myapp/
169
+ # - run: npx wrangler pages deploy out --project-name=myapp
170
+ # - run: npx netlify deploy --prod --dir=out
171
+ ```
172
+
173
+ ---
174
+
175
+ ## Troubleshooting
176
+
177
+ - **404 on hard refresh of a deep link.** Your host did not pick up SPA
178
+ fallback. nginx: check `try_files`. CF/Netlify: `_redirects` is present?
179
+ S3+CloudFront: configure the 404 → `/index.html` (200) error response.
180
+ - **HTML is cached forever.** Either your host sent a default
181
+ `Cache-Control: public, max-age=...` or you are sitting behind a CDN that
182
+ ignores `no-cache`. Add an explicit rule mirroring the matrix above.
183
+ - **`/assets/*` files change but the browser keeps the old one.** They
184
+ should not — the filename is hashed by `mado bundle`. If you bypassed
185
+ bundle and shipped your own `dist/main.js`, give it a hash or short cache.
186
+ - **Baked SEO page shows `[object Object]`.** Should never happen after the
187
+ v1 bake update — bake now raises a loud error in that case. If you see it,
188
+ upgrade `@madojs/mado` and re-run `mado bake`.
189
+
190
+ See also: [`02-project-layout.md`](./02-project-layout.md) for the
191
+ `src/`/`dist/`/`public/`/`out/` model and [`03-static-bake.md`](./03-static-bake.md)
192
+ for the SEO bake mechanics.
@@ -0,0 +1,82 @@
1
+ # Testing
2
+
3
+ Mado projects are plain TypeScript and browser APIs, so tests should stay plain
4
+ too. The framework repository uses Node's built-in test runner plus `linkedom`
5
+ for DOM tests.
6
+
7
+ ## Commands
8
+
9
+ ```bash
10
+ npm run typecheck
11
+ npm run build
12
+ npm test
13
+ ```
14
+
15
+ Before publishing or merging a release branch, run all three.
16
+
17
+ ## Unit tests with DOM
18
+
19
+ ```js
20
+ import test from "node:test";
21
+ import assert from "node:assert/strict";
22
+
23
+ const { parseHTML } = await import("linkedom");
24
+ const { window } = parseHTML("<!doctype html><html><body></body></html>");
25
+ globalThis.window = window;
26
+ globalThis.document = window.document;
27
+ globalThis.Node = window.Node;
28
+ globalThis.HTMLElement = window.HTMLElement;
29
+
30
+ const { html, render } = await import("../dist/src/html.js");
31
+
32
+ test("renders a value", () => {
33
+ const root = document.createElement("div");
34
+ render(html`<p>${"hello"}</p>`, root);
35
+ assert.equal(root.querySelector("p").textContent, "hello");
36
+ });
37
+ ```
38
+
39
+ Build first, then import from `dist/`. This tests the same files the browser
40
+ will load.
41
+
42
+ ## What to cover
43
+
44
+ Test behavior, not internal implementation details:
45
+
46
+ - signal/computed scheduling and cleanup;
47
+ - template binding edges: child values, attributes, events, directives;
48
+ - route guards, redirects, scroll/focus behavior, error boundaries;
49
+ - forms: validation, async validation races, field arrays;
50
+ - resources/mutations: cache keys, invalidation, lifecycle cleanup;
51
+ - CLI flows: `mado release`, `mado bake`, `mado preview`.
52
+
53
+ ## Browser smoke tests
54
+
55
+ Use Playwright or another browser runner for flows that require real layout,
56
+ focus, navigation or custom-element lifecycle. Keep them small:
57
+
58
+ 1. start the app;
59
+ 2. visit one route;
60
+ 3. click one link or submit one form;
61
+ 4. assert the user-visible result.
62
+
63
+ Most regression tests should still be fast Node tests.
64
+
65
+ ## Test data
66
+
67
+ Keep API data in local fixtures or tiny in-memory fake clients. Do not call real
68
+ services from framework tests. For app tests, make the API client injectable via
69
+ `createContext()` so pages can run against a fake client.
70
+
71
+ ## Release checklist
72
+
73
+ ```bash
74
+ npm run typecheck
75
+ npm run build
76
+ npm test
77
+ npm run bundle
78
+ npm run bake
79
+ ```
80
+
81
+ `mado release` runs the production path for an app. In the framework repository,
82
+ the lower-level commands remain useful when debugging a single stage.
@@ -0,0 +1,100 @@
1
+ # Error handling
2
+
3
+ Mado has three practical error layers: route loading, data loading, and user
4
+ actions. Handle each layer where the user can recover.
5
+
6
+ ## Route errors
7
+
8
+ Use a global `errorPage` in `routes()` for lazy import, `load()` and `view()`
9
+ failures.
10
+
11
+ ```ts
12
+ export default routes(manifest, {
13
+ errorPage: (err) => html`
14
+ <main>
15
+ <h1>Something went wrong</h1>
16
+ <pre>${err.message}</pre>
17
+ <a data-link href="/">Go home</a>
18
+ </main>
19
+ `,
20
+ });
21
+ ```
22
+
23
+ For a specific page, `page({ errorView })` wins over the global route boundary.
24
+
25
+ ```ts
26
+ export default page({
27
+ load: async () => api.get("/reports"),
28
+ errorView: (err) => html`<x-report-error .error=${err}></x-report-error>`,
29
+ view: ({ data }) => html`<x-report .data=${data}></x-report>`,
30
+ });
31
+ ```
32
+
33
+ ## Resource errors
34
+
35
+ `resource()` exposes `error()` and `loading()`. Render a retry path near the data.
36
+
37
+ ```ts
38
+ const users = resource(() => "/api/users", jsonFetcher<User[]>());
39
+
40
+ html`
41
+ ${() => users.error()
42
+ ? html`<p role="alert">${users.error()!.message}</p>
43
+ <button @click=${users.refresh}>Retry</button>`
44
+ : null}
45
+ `;
46
+ ```
47
+
48
+ Use `HttpError` or your own API error type when the UI needs status codes.
49
+
50
+ ## Form and mutation errors
51
+
52
+ Validation errors belong in `useForm()`. Server errors from writes belong near
53
+ the submit button.
54
+
55
+ ```ts
56
+ const form = useForm(
57
+ { email: { required: true, type: "email" } },
58
+ { validateAsync: (values) => api.validateUser(values) },
59
+ );
60
+
61
+ const save = mutation((values) => api.post("/users", values), {
62
+ invalidates: ["/api/users*"],
63
+ });
64
+
65
+ html`
66
+ <form @submit=${form.onSubmit(async values => {
67
+ await save.run(values);
68
+ })}>
69
+ <button ?disabled=${() => form.validating() || form.submitting()}>
70
+ Save
71
+ </button>
72
+ ${() => save.error() ? html`<p role="alert">${save.error()!.message}</p>` : null}
73
+ </form>
74
+ `;
75
+ ```
76
+
77
+ ## Component cleanup
78
+
79
+ If you subscribe to external browser APIs, clean them with `ctx.onDispose()`.
80
+ Signals, effects and resources created inside setup are lifecycle-aware.
81
+
82
+ ```ts
83
+ component("x-online", (ctx) => {
84
+ const online = signal(navigator.onLine);
85
+ const onChange = () => online.set(navigator.onLine);
86
+ window.addEventListener("online", onChange);
87
+ window.addEventListener("offline", onChange);
88
+ ctx.onDispose(() => {
89
+ window.removeEventListener("online", onChange);
90
+ window.removeEventListener("offline", onChange);
91
+ });
92
+ return () => html`${() => online() ? "Online" : "Offline"}`;
93
+ });
94
+ ```
95
+
96
+ ## Logging rule
97
+
98
+ Log once at the boundary that owns recovery. Avoid logging the same failure in
99
+ the API client, resource, page and component. The user should get one visible
100
+ message and developers should get one useful console error.