@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,141 @@
1
+ # App architecture
2
+
3
+ This is the default shape for a production Mado app. It is intentionally boring:
4
+ one route manifest, one shell, one API client, one auth module, and page files
5
+ that own their feature components.
6
+
7
+ ## File tree
8
+
9
+ ```txt
10
+ src/
11
+ ├── main.ts
12
+ ├── routes.ts
13
+ ├── layouts/
14
+ │ ├── app.ts
15
+ │ └── auth.ts
16
+ ├── pages/
17
+ │ ├── home.ts
18
+ │ ├── login.ts
19
+ │ ├── not-found.ts
20
+ │ └── admin/
21
+ │ ├── dashboard.ts
22
+ │ ├── orders.ts
23
+ │ └── order-detail.ts
24
+ ├── components/
25
+ │ ├── x-button.ts
26
+ │ └── x-input.ts
27
+ ├── lib/
28
+ │ ├── api.ts
29
+ │ └── auth.ts
30
+ └── styles/
31
+ └── global.ts
32
+ ```
33
+
34
+ Keep business logic in `lib/`, route wrapping in `layouts/`, and UI leaves in
35
+ `components/`. A page should import the components it renders.
36
+
37
+ ## Entry point
38
+
39
+ ```ts
40
+ // src/main.ts
41
+ import { html, render } from "@madojs/mado";
42
+ import "./styles/global.js";
43
+ import "./components/x-button.js";
44
+ import routesApi from "./routes.js";
45
+
46
+ render(html`${routesApi.view}`, document.getElementById("app")!);
47
+ ```
48
+
49
+ Import global providers and tiny shared components here. Do not bulk-import
50
+ every feature component.
51
+
52
+ ## Routes
53
+
54
+ ```ts
55
+ // src/routes.ts
56
+ import { layout, routes } from "@madojs/mado";
57
+ import { requireAuth } from "./lib/auth.js";
58
+
59
+ export const manifest = {
60
+ "/": () => import("./pages/home.js"),
61
+ "/login": layout({
62
+ layout: () => import("./layouts/auth.js"),
63
+ routes: { "/": () => import("./pages/login.js") },
64
+ }),
65
+ "/admin": layout({
66
+ layout: () => import("./layouts/app.js"),
67
+ guard: requireAuth,
68
+ routes: {
69
+ "/": () => import("./pages/admin/dashboard.js"),
70
+ "/orders": () => import("./pages/admin/orders.js"),
71
+ "/orders/:id": () => import("./pages/admin/order-detail.js"),
72
+ },
73
+ }),
74
+ "*": () => import("./pages/not-found.js"),
75
+ };
76
+
77
+ export default routes(manifest, {
78
+ errorPage: (err) => html`<main><h1>Something went wrong</h1><pre>${err.message}</pre></main>`,
79
+ });
80
+ ```
81
+
82
+ Exporting `manifest` lets `mado bake` inspect the same route table.
83
+
84
+ ## API and auth
85
+
86
+ Use one API client and one auth module. The admin starter ships a complete
87
+ version with token storage, a single-flight refresh request, and a route guard.
88
+
89
+ ```ts
90
+ // pages/admin/orders.ts
91
+ import { each, html, page, resource } from "@madojs/mado";
92
+ import { api } from "../../lib/api.js";
93
+
94
+ const orders = resource(() => "/api/orders", () => api.get("/orders"));
95
+
96
+ export default page({
97
+ title: "Orders",
98
+ view: () => html`
99
+ <main>
100
+ <h1>Orders</h1>
101
+ <ul>
102
+ ${() => each(orders.data() ?? [], o => o.id, o => html`<li>${o.number}</li>`)}
103
+ </ul>
104
+ </main>
105
+ `,
106
+ });
107
+ ```
108
+
109
+ Mutations should declare invalidation near the write:
110
+
111
+ ```ts
112
+ const save = mutation((payload) => api.post("/orders", payload), {
113
+ invalidates: ["/api/orders*"],
114
+ });
115
+ ```
116
+
117
+ ## Forms
118
+
119
+ Prefer one `useForm()` per user workflow.
120
+
121
+ ```ts
122
+ const form = useForm({
123
+ email: { required: true, type: "email" },
124
+ "items.*.title": { required: true },
125
+ });
126
+ const items = form.array("items");
127
+ ```
128
+
129
+ Use dotted paths for arrays (`items.0.title`) and keep async validation in
130
+ `validateAsync` when it talks to the backend.
131
+
132
+ ## Release
133
+
134
+ Local development uses `mado dev`. Production uses exactly one artifact:
135
+
136
+ ```bash
137
+ mado release
138
+ rsync -avz out/ user@server:/var/www/app/
139
+ ```
140
+
141
+ `out/` is the deploy folder. `dist/` is internal build output.
@@ -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.