@madojs/mado 0.5.1 → 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.
- package/AGENTS.md +26 -0
- package/CHANGELOG.md +153 -0
- package/MADO_V1_PLAN.md +179 -0
- package/README.md +31 -13
- package/ROADMAP.md +28 -7
- package/TODO.md +72 -0
- package/dist/src/forms.d.ts +37 -4
- package/dist/src/forms.js +331 -57
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.d.ts +41 -0
- package/dist/src/html/bindings.js +163 -6
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html.d.ts +2 -0
- package/dist/src/html.js +1 -0
- package/dist/src/html.js.map +1 -1
- package/dist/src/index.d.ts +6 -6
- package/dist/src/index.js +2 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +56 -0
- package/dist/src/page.js +17 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/router/manifest.d.ts +16 -1
- package/dist/src/router/manifest.js +181 -38
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +7 -2
- package/dist/src/router/match.js +14 -4
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.d.ts +10 -0
- package/dist/src/router/navigation.js +71 -3
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.d.ts +15 -1
- package/dist/src/signal.js +112 -16
- package/dist/src/signal.js.map +1 -1
- package/docs/en/02-project-layout.md +99 -40
- package/docs/en/10-app-architecture.md +141 -0
- package/docs/en/11-layouts.md +115 -0
- package/docs/en/12-auth-and-api.md +217 -0
- package/docs/en/13-deployment.md +192 -0
- package/docs/en/14-testing.md +82 -0
- package/docs/en/15-error-handling.md +100 -0
- package/docs/en/16-bake-cookbook.md +93 -0
- package/docs/en/README.md +7 -0
- package/docs/fr/10-app-architecture.md +61 -0
- package/docs/fr/11-layouts.md +35 -0
- package/docs/fr/12-auth-and-api.md +35 -0
- package/docs/fr/13-deployment.md +39 -0
- package/docs/fr/14-testing.md +41 -0
- package/docs/fr/15-error-handling.md +50 -0
- package/docs/fr/16-bake-cookbook.md +35 -0
- package/docs/fr/README.md +7 -0
- package/docs/ru/10-app-architecture.md +100 -0
- package/docs/ru/11-layouts.md +47 -0
- package/docs/ru/12-auth-and-api.md +53 -0
- package/docs/ru/13-deployment.md +60 -0
- package/docs/ru/14-testing.md +50 -0
- package/docs/ru/15-error-handling.md +56 -0
- package/docs/ru/16-bake-cookbook.md +55 -0
- package/docs/ru/README.md +7 -0
- package/docs/uk/10-app-architecture.md +56 -0
- package/docs/uk/11-layouts.md +34 -0
- package/docs/uk/12-auth-and-api.md +34 -0
- package/docs/uk/13-deployment.md +39 -0
- package/docs/uk/14-testing.md +34 -0
- package/docs/uk/15-error-handling.md +32 -0
- package/docs/uk/16-bake-cookbook.md +36 -0
- package/docs/uk/README.md +7 -0
- package/llms.txt +9 -1
- package/package.json +3 -1
- package/scripts/_config.mjs +224 -0
- package/scripts/bake.mjs +217 -120
- package/scripts/bundle.mjs +110 -67
- package/scripts/cli.mjs +119 -15
- package/scripts/preview.mjs +22 -12
- package/server/serve.mjs +82 -4
- package/starters/admin/README.md +63 -0
- package/starters/admin/index.html +21 -0
- package/starters/admin/mado.config.json +22 -0
- package/starters/admin/package.json +22 -0
- package/starters/admin/public/favicon.svg +4 -0
- package/starters/admin/src/components/x-button.ts +55 -0
- package/starters/admin/src/components/x-input.ts +74 -0
- package/starters/admin/src/layouts/app.ts +101 -0
- package/starters/admin/src/layouts/auth.ts +41 -0
- package/starters/admin/src/lib/api.ts +133 -0
- package/starters/admin/src/lib/auth.ts +83 -0
- package/starters/admin/src/main.ts +15 -0
- package/starters/admin/src/pages/admin/dashboard.ts +48 -0
- package/starters/admin/src/pages/admin/order-detail.ts +78 -0
- package/starters/admin/src/pages/admin/orders.ts +117 -0
- package/starters/admin/src/pages/home.ts +25 -0
- package/starters/admin/src/pages/login.ts +70 -0
- package/starters/admin/src/pages/not-found.ts +12 -0
- package/starters/admin/src/routes.ts +40 -0
- package/starters/admin/src/styles/global.ts +86 -0
- package/starters/admin/tsconfig.json +15 -0
- package/starters/crud/mado.config.json +20 -0
- package/starters/crud/package.json +8 -4
- package/starters/crud/src/routes.ts +4 -2
- package/starters/minimal/mado.config.json +20 -0
- package/starters/minimal/package.json +7 -3
- package/starters/minimal/src/routes.ts +4 -2
|
@@ -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.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Bake cookbook
|
|
2
|
+
|
|
3
|
+
`mado bake` renders selected routes into static HTML. It is for SEO and fast
|
|
4
|
+
first paint, not for server-side hydration.
|
|
5
|
+
|
|
6
|
+
## Minimal baked page
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
export default page({
|
|
10
|
+
head: () => ({ title: "Products", description: "Product catalog" }),
|
|
11
|
+
view: ({ data }) => html`
|
|
12
|
+
<main>
|
|
13
|
+
<h1>Products</h1>
|
|
14
|
+
${data.products.map((p) => html`<article><h2>${p.name}</h2></article>`)}
|
|
15
|
+
</main>
|
|
16
|
+
`,
|
|
17
|
+
bake: {
|
|
18
|
+
paths: () => [{}],
|
|
19
|
+
data: async () => ({ products: await api.products() }),
|
|
20
|
+
revalidate: 3600,
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
For baked pages, use plain arrays in `view()` when possible. Runtime-only
|
|
26
|
+
directives such as keyed `each()` are for the browser.
|
|
27
|
+
|
|
28
|
+
## Dynamic routes
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
export default page<{ slug: string }>({
|
|
32
|
+
head: ({ slug }, data) => ({ title: data.title, canonical: `/blog/${slug}` }),
|
|
33
|
+
view: ({ data }) => html`<article>${unsafeHTML(data.html)}</article>`,
|
|
34
|
+
bake: {
|
|
35
|
+
paths: async () => (await api.posts()).map((p) => ({ slug: p.slug })),
|
|
36
|
+
data: ({ slug }) => api.post(slug),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`unsafeHTML()` is allowed only for trusted or already-sanitized HTML.
|
|
42
|
+
|
|
43
|
+
## Route manifest
|
|
44
|
+
|
|
45
|
+
`mado bake` needs the source manifest:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
export const manifest = {
|
|
49
|
+
"/": () => import("./pages/home.js"),
|
|
50
|
+
"/blog/:slug": () => import("./pages/blog-post.js"),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export default routes(manifest);
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Output
|
|
57
|
+
|
|
58
|
+
By default app-mode writes baked pages under `out/baked/`. `mado release`
|
|
59
|
+
produces the final deploy artifact:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
mado release
|
|
63
|
+
tree out
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The deployable folder is `out/`, not `dist/`.
|
|
67
|
+
|
|
68
|
+
## Unsupported values
|
|
69
|
+
|
|
70
|
+
Bake intentionally fails loudly instead of writing `[object Object]`. If a baked
|
|
71
|
+
view throws an unsupported directive error:
|
|
72
|
+
|
|
73
|
+
- replace `each()` with `items.map(...)` in baked markup;
|
|
74
|
+
- keep interactive-only widgets behind client routes;
|
|
75
|
+
- make sure every value can be serialized to static HTML.
|
|
76
|
+
|
|
77
|
+
## Canonical links
|
|
78
|
+
|
|
79
|
+
Pass `--base-url` or set `bake.baseUrl` in `mado.config.json` so generated
|
|
80
|
+
canonical links and sitemap entries point to production.
|
|
81
|
+
|
|
82
|
+
```json
|
|
83
|
+
{
|
|
84
|
+
"bake": {
|
|
85
|
+
"baseUrl": "https://example.com"
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## When not to bake
|
|
91
|
+
|
|
92
|
+
Do not bake pages whose content is user-specific, permission-dependent, or
|
|
93
|
+
changes every few seconds. Use a normal SPA route with `resource()` instead.
|
package/docs/en/README.md
CHANGED
|
@@ -14,3 +14,10 @@ English documentation set.
|
|
|
14
14
|
| LLM pitfalls | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
|
|
15
15
|
| LLM zero-history test | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
|
|
16
16
|
| Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
|
|
17
|
+
| App architecture | [10-app-architecture.md](./10-app-architecture.md) |
|
|
18
|
+
| Layouts | [11-layouts.md](./11-layouts.md) |
|
|
19
|
+
| Auth and API | [12-auth-and-api.md](./12-auth-and-api.md) |
|
|
20
|
+
| Deployment | [13-deployment.md](./13-deployment.md) |
|
|
21
|
+
| Testing | [14-testing.md](./14-testing.md) |
|
|
22
|
+
| Error handling | [15-error-handling.md](./15-error-handling.md) |
|
|
23
|
+
| Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
|