@madojs/mado 0.11.0 → 0.12.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 (221) hide show
  1. package/AGENTS.md +102 -25
  2. package/CHANGELOG.md +185 -1
  3. package/README.md +137 -172
  4. package/dist/src/component.d.ts +3 -2
  5. package/dist/src/component.js +98 -6
  6. package/dist/src/component.js.map +1 -1
  7. package/dist/src/css.js +34 -5
  8. package/dist/src/css.js.map +1 -1
  9. package/dist/src/head.d.ts +6 -6
  10. package/dist/src/head.js +6 -6
  11. package/dist/src/html/bindings.js +3 -3
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html/parser.d.ts +1 -1
  14. package/dist/src/html/parser.js +1 -1
  15. package/dist/src/html/template.js +24 -10
  16. package/dist/src/html/template.js.map +1 -1
  17. package/dist/src/index.d.ts +8 -4
  18. package/dist/src/index.js +13 -3
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/page.d.ts +62 -28
  21. package/dist/src/page.js +5 -0
  22. package/dist/src/page.js.map +1 -1
  23. package/dist/src/resource.js +2 -1
  24. package/dist/src/resource.js.map +1 -1
  25. package/dist/src/router/base.d.ts +65 -0
  26. package/dist/src/router/base.js +168 -0
  27. package/dist/src/router/base.js.map +1 -0
  28. package/dist/src/router/manifest.js +75 -31
  29. package/dist/src/router/manifest.js.map +1 -1
  30. package/dist/src/router/match.d.ts +16 -2
  31. package/dist/src/router/match.js +41 -1
  32. package/dist/src/router/match.js.map +1 -1
  33. package/dist/src/router/navigation.js +58 -9
  34. package/dist/src/router/navigation.js.map +1 -1
  35. package/dist/src/static-runtime.d.ts +81 -0
  36. package/dist/src/static-runtime.js +209 -0
  37. package/dist/src/static-runtime.js.map +1 -0
  38. package/dist/src/vite/index.d.ts +31 -3
  39. package/dist/src/vite/index.js +49 -18
  40. package/dist/src/vite/index.js.map +1 -1
  41. package/docs/README.md +5 -8
  42. package/docs/architecture/adr/0001-browser-static-snapshots.md +132 -0
  43. package/docs/en/00-the-mado-way.md +1 -1
  44. package/docs/en/01-quickstart.md +183 -0
  45. package/docs/en/10-pages-and-components.md +271 -0
  46. package/docs/en/11-templates-and-signals.md +211 -0
  47. package/docs/en/12-routing.md +229 -0
  48. package/docs/en/13-data.md +225 -0
  49. package/docs/en/14-forms.md +244 -0
  50. package/docs/en/15-static-snapshots.md +181 -0
  51. package/docs/en/{10-app-architecture.md → 16-app-architecture.md} +53 -2
  52. package/docs/en/{13-deployment.md → 20-deployment.md} +59 -10
  53. package/docs/en/{14-testing.md → 22-testing.md} +6 -5
  54. package/docs/en/23-cookbook.md +292 -0
  55. package/docs/en/{18-api-freeze-map.md → 30-api-freeze-map.md} +12 -3
  56. package/docs/en/{20-v1-stability.md → 32-v1-stability.md} +3 -3
  57. package/docs/en/40-llm-guide.md +776 -0
  58. package/docs/en/{06-for-backenders.md → 41-for-backenders.md} +15 -16
  59. package/docs/en/{05-why-mado.md → 42-why-mado.md} +1 -1
  60. package/docs/en/README.md +97 -27
  61. package/docs/recipes/nginx/Containerfile +26 -0
  62. package/docs/recipes/nginx/nginx.conf +42 -0
  63. package/llms.txt +246 -211
  64. package/package.json +17 -10
  65. package/scripts/bake.mjs +4 -568
  66. package/scripts/cli/generate.mjs +55 -5
  67. package/scripts/cli/help.mjs +11 -5
  68. package/scripts/cli/index.mjs +20 -4
  69. package/scripts/cli/init.mjs +7 -2
  70. package/scripts/cli/release.mjs +20 -68
  71. package/scripts/docs-lint.mjs +170 -0
  72. package/scripts/package-smoke.mjs +67 -9
  73. package/scripts/preview.mjs +95 -6
  74. package/scripts/size-budget.mjs +5 -1
  75. package/scripts/static/browser.mjs +654 -0
  76. package/scripts/static/discover.mjs +281 -0
  77. package/scripts/static/output.mjs +141 -0
  78. package/scripts/static/serialize.mjs +212 -0
  79. package/scripts/static/server.mjs +167 -0
  80. package/scripts/static.mjs +191 -0
  81. package/starters/default/README.md +57 -55
  82. package/starters/default/package.json +3 -9
  83. package/starters/default/src/app.routes.ts +20 -33
  84. package/starters/default/src/components/feature-card.component.ts +40 -0
  85. package/starters/default/src/components/live-counter.component.ts +57 -0
  86. package/starters/default/src/content/guides.ts +55 -0
  87. package/starters/default/src/main.ts +9 -13
  88. package/starters/default/src/pages/app.page.ts +41 -0
  89. package/starters/default/src/pages/guide.page.ts +40 -0
  90. package/starters/default/src/pages/home.page.ts +49 -0
  91. package/starters/default/src/pages/not-found.page.ts +18 -0
  92. package/starters/default/src/styles/document.css +39 -0
  93. package/starters/default/src/styles/reset.css +37 -0
  94. package/starters/default/src/styles/tokens.css +30 -0
  95. package/starters/default/src/styles.d.ts +3 -1
  96. package/starters/default/src/vite-env.d.ts +1 -1
  97. package/starters/default/vite.config.ts +14 -1
  98. package/starters/modular/README.md +142 -0
  99. package/starters/modular/index.html +13 -0
  100. package/starters/modular/package.json +30 -0
  101. package/starters/modular/src/app.routes.ts +39 -0
  102. package/starters/{default → modular}/src/layouts/app-shell.layout.ts +8 -5
  103. package/starters/{default → modular}/src/layouts/auth-shell.layout.ts +3 -3
  104. package/starters/modular/src/main.ts +16 -0
  105. package/starters/{default → modular}/src/modules/billing/pages/invoices-list.page.ts +3 -3
  106. package/starters/{default → modular}/src/modules/home/home.page.ts +5 -7
  107. package/starters/modular/src/modules/home/not-found.page.ts +14 -0
  108. package/starters/modular/src/styles.d.ts +1 -0
  109. package/starters/modular/src/vite-env.d.ts +1 -0
  110. package/starters/modular/tsconfig.json +24 -0
  111. package/starters/modular/vite.config.ts +131 -0
  112. package/TODO.md +0 -79
  113. package/docs/en/01-routing.md +0 -152
  114. package/docs/en/02-project-layout.md +0 -124
  115. package/docs/en/03-static-bake.md +0 -249
  116. package/docs/en/04-ide-setup.md +0 -162
  117. package/docs/en/07-llm-pitfalls.md +0 -724
  118. package/docs/en/08-llm-zero-history-test.md +0 -56
  119. package/docs/en/09-shadow-vs-light-dom.md +0 -174
  120. package/docs/en/11-layouts.md +0 -113
  121. package/docs/en/12-auth-and-api.md +0 -124
  122. package/docs/en/16-bake-cookbook.md +0 -100
  123. package/docs/en/17-shadow-dom-forms.md +0 -192
  124. package/docs/fr/00-the-mado-way.md +0 -85
  125. package/docs/fr/01-routing.md +0 -120
  126. package/docs/fr/02-project-layout.md +0 -84
  127. package/docs/fr/03-static-bake.md +0 -289
  128. package/docs/fr/04-ide-setup.md +0 -162
  129. package/docs/fr/05-why-mado.md +0 -193
  130. package/docs/fr/06-for-backenders.md +0 -438
  131. package/docs/fr/07-llm-pitfalls.md +0 -625
  132. package/docs/fr/08-llm-zero-history-test.md +0 -38
  133. package/docs/fr/09-shadow-vs-light-dom.md +0 -65
  134. package/docs/fr/10-app-architecture.md +0 -138
  135. package/docs/fr/11-layouts.md +0 -47
  136. package/docs/fr/12-auth-and-api.md +0 -76
  137. package/docs/fr/13-deployment.md +0 -57
  138. package/docs/fr/14-testing.md +0 -41
  139. package/docs/fr/15-error-handling.md +0 -50
  140. package/docs/fr/16-bake-cookbook.md +0 -88
  141. package/docs/fr/17-shadow-dom-forms.md +0 -196
  142. package/docs/fr/18-api-freeze-map.md +0 -63
  143. package/docs/fr/19-reactivity-ordering.md +0 -97
  144. package/docs/fr/20-v1-stability.md +0 -88
  145. package/docs/fr/README.md +0 -27
  146. package/docs/ru/00-the-mado-way.md +0 -84
  147. package/docs/ru/01-routing.md +0 -119
  148. package/docs/ru/02-project-layout.md +0 -84
  149. package/docs/ru/03-static-bake.md +0 -250
  150. package/docs/ru/04-ide-setup.md +0 -144
  151. package/docs/ru/05-why-mado.md +0 -193
  152. package/docs/ru/06-for-backenders.md +0 -428
  153. package/docs/ru/07-llm-pitfalls.md +0 -624
  154. package/docs/ru/08-llm-zero-history-test.md +0 -57
  155. package/docs/ru/09-shadow-vs-light-dom.md +0 -63
  156. package/docs/ru/10-app-architecture.md +0 -152
  157. package/docs/ru/11-layouts.md +0 -47
  158. package/docs/ru/12-auth-and-api.md +0 -75
  159. package/docs/ru/13-deployment.md +0 -66
  160. package/docs/ru/14-testing.md +0 -50
  161. package/docs/ru/15-error-handling.md +0 -56
  162. package/docs/ru/16-bake-cookbook.md +0 -95
  163. package/docs/ru/17-shadow-dom-forms.md +0 -193
  164. package/docs/ru/18-api-freeze-map.md +0 -64
  165. package/docs/ru/19-reactivity-ordering.md +0 -95
  166. package/docs/ru/20-v1-stability.md +0 -82
  167. package/docs/ru/README.md +0 -25
  168. package/docs/uk/00-the-mado-way.md +0 -82
  169. package/docs/uk/01-routing.md +0 -76
  170. package/docs/uk/02-project-layout.md +0 -73
  171. package/docs/uk/03-static-bake.md +0 -48
  172. package/docs/uk/04-ide-setup.md +0 -26
  173. package/docs/uk/05-why-mado.md +0 -34
  174. package/docs/uk/06-for-backenders.md +0 -55
  175. package/docs/uk/07-llm-pitfalls.md +0 -145
  176. package/docs/uk/08-llm-zero-history-test.md +0 -34
  177. package/docs/uk/09-shadow-vs-light-dom.md +0 -58
  178. package/docs/uk/10-app-architecture.md +0 -97
  179. package/docs/uk/11-layouts.md +0 -47
  180. package/docs/uk/12-auth-and-api.md +0 -70
  181. package/docs/uk/13-deployment.md +0 -40
  182. package/docs/uk/14-testing.md +0 -34
  183. package/docs/uk/15-error-handling.md +0 -32
  184. package/docs/uk/16-bake-cookbook.md +0 -36
  185. package/docs/uk/17-shadow-dom-forms.md +0 -193
  186. package/docs/uk/18-api-freeze-map.md +0 -61
  187. package/docs/uk/19-reactivity-ordering.md +0 -95
  188. package/docs/uk/20-v1-stability.md +0 -83
  189. package/docs/uk/README.md +0 -27
  190. package/starters/default/src/modules/home/not-found.page.ts +0 -11
  191. /package/docs/en/{15-error-handling.md → 21-error-handling.md} +0 -0
  192. /package/docs/en/{19-reactivity-ordering.md → 31-reactivity-ordering.md} +0 -0
  193. /package/starters/{default → modular}/.editorconfig +0 -0
  194. /package/starters/{default → modular}/eslint.config.mjs +0 -0
  195. /package/starters/{default → modular}/public/favicon.svg +0 -0
  196. /package/starters/{default → modular}/src/modules/auth/_contracts/auth-api.types.ts +0 -0
  197. /package/starters/{default → modular}/src/modules/auth/auth.connector.ts +0 -0
  198. /package/starters/{default → modular}/src/modules/auth/auth.guard.ts +0 -0
  199. /package/starters/{default → modular}/src/modules/auth/auth.public.ts +0 -0
  200. /package/starters/{default → modular}/src/modules/auth/auth.routes.ts +0 -0
  201. /package/starters/{default → modular}/src/modules/auth/auth.service.ts +0 -0
  202. /package/starters/{default → modular}/src/modules/auth/auth.types.ts +0 -0
  203. /package/starters/{default → modular}/src/modules/auth/login.page.ts +0 -0
  204. /package/starters/{default → modular}/src/modules/billing/_contracts/stripe.types.ts +0 -0
  205. /package/starters/{default → modular}/src/modules/billing/api/stripe.connector.ts +0 -0
  206. /package/starters/{default → modular}/src/modules/billing/billing.public.ts +0 -0
  207. /package/starters/{default → modular}/src/modules/billing/billing.routes.ts +0 -0
  208. /package/starters/{default → modular}/src/modules/billing/billing.types.ts +0 -0
  209. /package/starters/{default → modular}/src/modules/billing/components/invoice-status-badge.component.ts +0 -0
  210. /package/starters/{default → modular}/src/modules/billing/data/invoices.resource.ts +0 -0
  211. /package/starters/{default → modular}/src/modules/billing/pages/invoice-detail.page.ts +0 -0
  212. /package/starters/{default → modular}/src/shared/http/http-client.ts +0 -0
  213. /package/starters/{default → modular}/src/shared/http/http-error.ts +0 -0
  214. /package/starters/{default → modular}/src/shared/http/interceptors.ts +0 -0
  215. /package/starters/{default → modular}/src/shared/lib/format-date.ts +0 -0
  216. /package/starters/{default → modular}/src/shared/styles/content.css +0 -0
  217. /package/starters/{default → modular}/src/shared/styles/reset.css +0 -0
  218. /package/starters/{default → modular}/src/shared/styles/shell.css +0 -0
  219. /package/starters/{default → modular}/src/shared/styles/tokens.css +0 -0
  220. /package/starters/{default → modular}/src/shared/ui/x-button.component.ts +0 -0
  221. /package/starters/{default → modular}/src/shared/ui/x-spinner.component.ts +0 -0
@@ -0,0 +1,225 @@
1
+ # Data
2
+
3
+ > `resource()` reads, `mutation()` writes, `jsonFetcher()` shapes
4
+ > the wire. Backends, auth and modules all sit on top of this layer.
5
+
6
+ Mado does not ship a generic HTTP client because the browser already
7
+ has one. Instead it ships two small primitives that wrap `fetch()`
8
+ into a cache + lifecycle:
9
+
10
+ - `resource()` — keyed cache, loading / error / data signals, glob
11
+ invalidation, automatic cleanup.
12
+ - `mutation()` — async action runner with the same loading / error /
13
+ data signals, plus declarative invalidation of related resources.
14
+
15
+ A third helper, `jsonFetcher()`, is the default body parser: parses
16
+ JSON, throws `HttpError` on `!ok`.
17
+
18
+ ## `resource()`
19
+
20
+ ```ts
21
+ import { resource, jsonFetcher } from "@madojs/mado";
22
+
23
+ const userId = signal(1);
24
+
25
+ const user = resource(
26
+ () => `/api/users/${userId()}`, // key (reactive — recomputed on change)
27
+ jsonFetcher<User>(), // how to load
28
+ { staleTime: 60_000 }, // cache age
29
+ );
30
+
31
+ user.data(); // User | undefined
32
+ user.error(); // Error | null
33
+ user.loading(); // boolean
34
+
35
+ await user.refresh();
36
+ user.mutate((u) => (u ? { ...u, name: "patched" } : u));
37
+ ```
38
+
39
+ Rules:
40
+
41
+ - The key is the cache identity. Same key → shared cache and shared
42
+ in-flight request, even across components.
43
+ - Distinct data needs distinct keys. Encode query params, auth scope
44
+ and tenant in the key string itself.
45
+ - `staleTime` is "how long after a successful fetch the cached value
46
+ is reused without a re-fetch". `0` means "always re-fetch on
47
+ remount".
48
+ - `resource()` inside a `component()` / `page()` cleans itself up
49
+ when the host leaves the DOM.
50
+
51
+ ## `mutation()`
52
+
53
+ ```ts
54
+ import { mutation } from "@madojs/mado";
55
+
56
+ const save = mutation<User, User>(
57
+ (u) =>
58
+ fetch("/api/users", {
59
+ method: "POST",
60
+ body: JSON.stringify(u),
61
+ }).then((r) => r.json()),
62
+ { invalidates: ["/api/users*"] },
63
+ );
64
+
65
+ await save.run(newUser); // throws on failure
66
+ save.loading(); // true while ANY run is in flight
67
+ save.error(); // last failure
68
+ save.data(); // last success
69
+ ```
70
+
71
+ - `run()` is concurrent by default. `loading()` stays true while any
72
+ run is in flight. Use `{ abortPrevious: true }` for
73
+ search-as-you-type ("latest request wins").
74
+ - `invalidates` runs after a successful mutation and is best-effort:
75
+ invalidation failures are logged but do not turn the mutation
76
+ itself into a failure.
77
+ - `invalidates` may also be a function of `(result, args)` for
78
+ dynamic keys.
79
+
80
+ ## `jsonFetcher()` and `HttpError`
81
+
82
+ ```ts
83
+ import { jsonFetcher, HttpError, resource } from "@madojs/mado";
84
+
85
+ const me = resource(() => "/api/auth/me", jsonFetcher<User>());
86
+
87
+ try {
88
+ await me.refresh();
89
+ } catch (err) {
90
+ if (err instanceof HttpError && err.status === 401) {
91
+ navigate("/login");
92
+ } else throw err;
93
+ }
94
+ ```
95
+
96
+ `HttpError` carries `status`, `url` and the parsed body (JSON when
97
+ possible, raw text otherwise). Use it for typed handling in pages
98
+ and guards.
99
+
100
+ ## Modular shape — connector → resource → page
101
+
102
+ The modular starter encodes one rule per layer; every business
103
+ module follows the same flow:
104
+
105
+ ```
106
+ connector → resource / mutation → page
107
+ ```
108
+
109
+ ```
110
+ src/shared/http/
111
+ http-client.ts # one fetch wrapper + query/body/error handling
112
+ http-error.ts # HttpError type
113
+ interceptors.ts # request/response hooks (auth header, retry, …)
114
+
115
+ src/modules/<name>/
116
+ <name>.connector.ts # external API contract, DTO -> domain
117
+ <name>.resource.ts # resource()/mutation() with cache keys
118
+ <name>.types.ts # domain types
119
+ pages/ # page()s — UI only
120
+ _contracts/ # backend DTO shapes, private to the connector
121
+ ```
122
+
123
+ Hard rules:
124
+
125
+ - Pages **never** import DTOs and **never** call `fetch()`.
126
+ - Connectors **never** import signals, resources, html, components,
127
+ pages or services.
128
+ - Public surface of a module is `<name>.public.ts`. Anything not
129
+ re-exported there is private.
130
+
131
+ The universal starter does not insist on this layering — for a small
132
+ app a single `src/lib/api.ts` is enough. Use the modular shape when
133
+ the app grows enough that "which file owns this fetch?" becomes a
134
+ real question.
135
+
136
+ ## Auth — service + guard
137
+
138
+ Auth state is an ES-module singleton built from the same primitives:
139
+
140
+ ```ts
141
+ // src/modules/auth/auth.service.ts
142
+ import { computed, signal } from "@madojs/mado";
143
+ import { authApi } from "./auth.connector";
144
+ import type { Credentials, User } from "./auth.types";
145
+
146
+ const _user = signal<User | null>(null);
147
+ const _token = signal<string | null>(null);
148
+
149
+ export const user = (): User | null => _user();
150
+ export const isAuthed = computed(() => _user() !== null);
151
+
152
+ export async function login(creds: Credentials): Promise<void> {
153
+ const res = await authApi.login(creds);
154
+ _token.set(res.token);
155
+ _user.set(res.user);
156
+ }
157
+
158
+ export async function logout(): Promise<void> {
159
+ try { await authApi.logout(); } catch { /* best effort */ }
160
+ _token.set(null);
161
+ _user.set(null);
162
+ }
163
+ ```
164
+
165
+ Expose only what other modules need through `auth.public.ts`.
166
+
167
+ The guard is a plain function returning `true | false | string`:
168
+
169
+ ```ts
170
+ // src/modules/auth/auth.guard.ts
171
+ import { isAuthed } from "./auth.service";
172
+
173
+ export function requireAuth(): boolean | string {
174
+ return isAuthed() ? true : "/login";
175
+ }
176
+ ```
177
+
178
+ Mount it in `src/app.routes.ts`:
179
+
180
+ ```ts
181
+ "/billing": layout({
182
+ layout: () => import("./layouts/app-shell.layout"),
183
+ guard: requireAuth,
184
+ routes: billingRoutes,
185
+ }),
186
+ ```
187
+
188
+ Full guard contract: [12-routing.md](./12-routing.md#guards).
189
+
190
+ ## Dev proxy
191
+
192
+ Configure backend proxying in `vite.config.ts`:
193
+
194
+ ```ts
195
+ import { defineConfig } from "vite";
196
+ import { mado } from "@madojs/mado/vite";
197
+
198
+ export default defineConfig({
199
+ plugins: [mado()],
200
+ server: {
201
+ proxy: { "/api": "http://localhost:3000" },
202
+ },
203
+ });
204
+ ```
205
+
206
+ The modular starter ships a dev-only in-memory mock for `/api/auth/*`
207
+ and `/api/billing/*` (`MADO_MOCK_API=0` to disable). Remove it once
208
+ your real backend is wired.
209
+
210
+ ## Rule of thumb
211
+
212
+ - `shared/http` knows HTTP.
213
+ - `*.connector.ts` knows one external system and one DTO shape.
214
+ - `*.resource.ts` knows cache keys and invalidation.
215
+ - `*.page.ts` knows UI.
216
+ - `*.public.ts` is the only cross-module surface.
217
+
218
+ ## Further reading
219
+
220
+ - [12-routing.md](./12-routing.md) — guards, layouts, `routeUrl`.
221
+ - [14-forms.md](./14-forms.md) — `useForm()`, then `mutation().run()`.
222
+ - [21-error-handling.md](./21-error-handling.md) — route / data /
223
+ action error boundaries.
224
+ - [16-app-architecture.md](./16-app-architecture.md) — the modular
225
+ starter shape end-to-end.
@@ -0,0 +1,244 @@
1
+ # Forms
2
+
3
+ > `useForm()` is a thin signal-wrapper around native HTML5 form
4
+ > validation. For 90% of cases you write a `<form>`, attach
5
+ > `onInput / onBlur / onSubmit` and you are done.
6
+
7
+ ```ts
8
+ import { html, page, useForm } from "@madojs/mado";
9
+
10
+ export default page({
11
+ view: () => {
12
+ const f = useForm({
13
+ email: { required: true, type: "email" },
14
+ age: { required: true, type: "number", min: 18 },
15
+ });
16
+
17
+ return html`
18
+ <form @submit=${f.onSubmit(async (v) => { await api.save(v); f.reset(); })}>
19
+ <input name="email" type="email"
20
+ @input=${f.onInput} @blur=${f.onBlur} />
21
+ ${() =>
22
+ f.touched().email && f.errors().email
23
+ ? html`<small class="err">${f.errors().email}</small>`
24
+ : null}
25
+
26
+ <input name="age" type="number" @input=${f.onInput} />
27
+
28
+ <button ?disabled=${() => !f.isValid() || f.submitting()}>Save</button>
29
+ </form>
30
+ `;
31
+ },
32
+ });
33
+ ```
34
+
35
+ ## The signals it exposes
36
+
37
+ | Reader | Value |
38
+ | ---------------------- | ---------------------------------------------------------------- |
39
+ | `f.values()` | object of current field values |
40
+ | `f.errors()` | object of `field → error string` (validators + HTML5 constraints)|
41
+ | `f.touched()` | object of `field → boolean` (true after first blur) |
42
+ | `f.isValid()` | true when `errors()` is empty |
43
+ | `f.submitting()` | true while the `onSubmit` async handler is running |
44
+ | `f.dirty()` | true when any value differs from initial |
45
+
46
+ Writers: `f.setValue(name, v)`, `f.setError(name, msg)`,
47
+ `f.reset(values?)`, `f.touch(name)`.
48
+
49
+ ## Schema
50
+
51
+ The schema mirrors HTML5 constraints, with a few extras:
52
+
53
+ ```ts
54
+ useForm({
55
+ email: { required: true, type: "email" },
56
+ age: { type: "number", min: 18, max: 120 },
57
+ name: { required: true, minLength: 2, maxLength: 80 },
58
+ website: { type: "url" },
59
+ tags: { custom: (v) => Array.isArray(v) && v.length > 0 ? null : "pick at least one" },
60
+ });
61
+ ```
62
+
63
+ For cross-field or async rules use `validate`:
64
+
65
+ ```ts
66
+ useForm({
67
+ password: { required: true, minLength: 8 },
68
+ confirm: { required: true },
69
+ }, {
70
+ validate: (values) =>
71
+ values.password !== values.confirm
72
+ ? { confirm: "passwords do not match" }
73
+ : null,
74
+ });
75
+ ```
76
+
77
+ Return `null` (everything is valid) or a partial `field → error`
78
+ object.
79
+
80
+ ## Field arrays
81
+
82
+ ```ts
83
+ const f = useForm({ tags: { custom: (v) => v.length > 0 ? null : "required" } });
84
+ const tags = f.fieldArray<string>("tags");
85
+
86
+ tags.append("rust");
87
+ tags.remove(0);
88
+ tags.move(0, 2);
89
+ tags.items(); // current array (signal)
90
+ ```
91
+
92
+ ## With `mutation()`
93
+
94
+ Forms compose with `mutation()` for typed write-paths:
95
+
96
+ ```ts
97
+ import { mutation, navigate } from "@madojs/mado";
98
+
99
+ const save = mutation((u: User) => api.save(u), {
100
+ invalidates: ["/api/users*"],
101
+ });
102
+
103
+ // inside view()
104
+ const f = useForm({ name: { required: true } });
105
+ return html`
106
+ <form @submit=${f.onSubmit(async (v) => {
107
+ await save.run(v);
108
+ navigate("/users");
109
+ })}>
110
+ <input name="name" @input=${f.onInput} />
111
+ <button ?disabled=${() => !f.isValid() || save.loading()}>Create</button>
112
+ ${() => save.error() ? html`<p class="err">${save.error()!.message}</p>` : null}
113
+ </form>
114
+ `;
115
+ ```
116
+
117
+ `save.loading()` reflects the request; `f.submitting()` reflects the
118
+ `onSubmit` handler runtime. Usually only one of them is interesting.
119
+
120
+ ## Shadow DOM inputs — when you need them
121
+
122
+ Most apps build forms from native `<input>` / `<select>` /
123
+ `<textarea>` directly inside the `<form>`. `useForm()` reads
124
+ `e.target.name` and `e.target.value` on the events, which works
125
+ out-of-the-box for native controls.
126
+
127
+ You only need a custom Web Component when the input has its own
128
+ shadow tree (a date picker, a tag editor, a rich text field, …). Two
129
+ browser-level facts make that path slightly different:
130
+
131
+ 1. **Event retargeting** — events that bubble out of an open shadow
132
+ root have their `target` retargeted to the host. `useForm()` reads
133
+ `e.target.name` and `e.target.value` — the host element must
134
+ expose both.
135
+ 2. **Form association** — a `<button type="submit">` inside a shadow
136
+ root is NOT part of the form-owner algorithm. Clicking it does
137
+ not submit the surrounding `<form>`.
138
+
139
+ The two patterns below cover both. Reach for them **only** if a real
140
+ custom input forces you to. For everything else, see "Light-DOM
141
+ inputs" further down.
142
+
143
+ ### Custom input — proxy `name` / `value`
144
+
145
+ ```ts
146
+ import { component, html } from "@madojs/mado";
147
+
148
+ component("x-input", ({ host, attr }) => {
149
+ const type = attr("type", "text");
150
+
151
+ // After Shadow DOM retargets e.target from the inner <input> to
152
+ // <x-input>, useForm reads e.target.name / e.target.value. These
153
+ // getters bridge the gap.
154
+ Object.defineProperty(host, "name", {
155
+ get: () => host.getAttribute("name") ?? "",
156
+ configurable: true,
157
+ });
158
+ Object.defineProperty(host, "value", {
159
+ get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
160
+ set: (v: string) => {
161
+ const input = host.shadowRoot?.querySelector("input");
162
+ if (input) input.value = v;
163
+ },
164
+ configurable: true,
165
+ });
166
+
167
+ return () => html`<input type=${type} />`;
168
+ });
169
+ ```
170
+
171
+ The inner `input` event already bubbles through the shadow boundary
172
+ (`composed: true` by default), so `<form @input=${f.onInput}>` keeps
173
+ working.
174
+
175
+ ### Custom submit button — `requestSubmit()`
176
+
177
+ ```ts
178
+ import { component, html } from "@madojs/mado";
179
+
180
+ component("x-button", ({ host, attr }) => {
181
+ const disabled = attr("disabled");
182
+
183
+ const onClick = () => {
184
+ const t = host.getAttribute("type");
185
+ if (t === "button" || t === "reset") return;
186
+ if (host.hasAttribute("disabled")) return;
187
+ host.closest("form")?.requestSubmit();
188
+ };
189
+
190
+ return () => html`
191
+ <button ?disabled=${() => disabled() !== ""} @click=${onClick}>
192
+ <slot></slot>
193
+ </button>
194
+ `;
195
+ });
196
+ ```
197
+
198
+ `host.closest("form")` works because the host itself lives in the
199
+ light DOM. `requestSubmit()` triggers HTML5 validation and the
200
+ `submit` event exactly like a native click.
201
+
202
+ ### Light-DOM input — the simpler alternative
203
+
204
+ If a custom element is just a styled wrapper around a native control
205
+ and does not need style encapsulation, use `{ shadow: false }`. The
206
+ native `<input>` is then part of the document; no retargeting, no
207
+ proxy properties, no submit bridge.
208
+
209
+ ```ts
210
+ component(
211
+ "x-field",
212
+ ({ attr }) => {
213
+ const label = attr("label", "");
214
+ return () => html`
215
+ <label>
216
+ <span>${label}</span>
217
+ <slot></slot>
218
+ </label>
219
+ `;
220
+ },
221
+ { shadow: false },
222
+ );
223
+ ```
224
+
225
+ This is the only situation where `shadow: false` is the recommended
226
+ default — and even then a `page()` is usually a better fit if the
227
+ wrapper is layout-shaped rather than reusable.
228
+
229
+ ## Summary
230
+
231
+ | Concern | Native form | Custom component with Shadow DOM |
232
+ | -------------------------- | ------------------------------------ | ----------------------------------------- |
233
+ | `useForm` integration | works out of the box | proxy `name` / `value` on the host |
234
+ | Submit button | `<button type="submit">` works | `host.closest("form")?.requestSubmit()` |
235
+ | Reactive attributes | not needed | `ctx.attr(name)` returns a Signal |
236
+ | Style encapsulation | global CSS | Shadow DOM CSS via `css\`\`` |
237
+
238
+ ## Further reading
239
+
240
+ - [10-pages-and-components.md](./10-pages-and-components.md) — when
241
+ to reach for `component({ shadow: false })`.
242
+ - [13-data.md](./13-data.md) — `mutation()` for typed writes.
243
+ - [21-error-handling.md](./21-error-handling.md) — surfacing form
244
+ errors from network failures.
@@ -0,0 +1,181 @@
1
+ # Static snapshots (`mado static`)
2
+
3
+ > Browser-rendered static HTML for SEO and first paint. No SSR, no
4
+ > hydration, no template-language bake.
5
+
6
+ `mado static` is a *snapshot capture*: at release time Mado runs your
7
+ app in a real headless Chromium, lets the page render to the DOM
8
+ (including any Shadow DOM), then serialises the result — Declarative
9
+ Shadow DOM and all — to one HTML file per route. On first paint the
10
+ live SPA atomically replaces the static tree; there is no hydration
11
+ protocol and no node reconciliation.
12
+
13
+ It is the right tool for content that is the same for every visitor:
14
+ public landing pages, product / catalogue pages, documentation, blog
15
+ posts. It is intentionally **not** an SSR runtime for personalised
16
+ data.
17
+
18
+ ## When `static` is suitable
19
+
20
+ - Public pages with a single canonical representation per URL.
21
+ - Pages whose data is known at build time (CMS dump, file system,
22
+ Markdown, JSON, build-time API call).
23
+ - Pages that need to appear fully rendered in `view-source:`, in
24
+ social preview crawlers, and to JS-disabled clients.
25
+ - First paint of a page that *later* upgrades into a live SPA route.
26
+
27
+ ## When `static` is **not** suitable
28
+
29
+ - Personalised content per visitor (auth, geo, A/B). The capture sees
30
+ one DOM and would ship it to everyone.
31
+ - Real-time data (chat, dashboards, prices). The snapshot is frozen
32
+ the moment of capture.
33
+ - Routes behind guards. Static pages are public by definition; the
34
+ discovery pipeline refuses to capture guarded routes.
35
+
36
+ For those cases keep the route SPA-only — Mado still serves a clean
37
+ `_mado/spa.html` fallback for them.
38
+
39
+ ## Declaring a static page
40
+
41
+ A page opts in through the `static` field on `page({ ... })`. Two
42
+ shapes are accepted:
43
+
44
+ ```ts
45
+ // Single static page (no params)
46
+ import { html, page } from "@madojs/mado";
47
+
48
+ export default page({
49
+ static: true,
50
+ title: "Pricing",
51
+ head: () => ({ description: "Plans and pricing." }),
52
+ view: () => html`<main><h1>Pricing</h1></main>`,
53
+ });
54
+ ```
55
+
56
+ ```ts
57
+ // Dynamic static route — one capture per `paths()` entry
58
+ import { html, page } from "@madojs/mado";
59
+
60
+ export default page<{ slug: string }, Guide>({
61
+ static: {
62
+ paths: async () => guides.map((g) => ({ slug: g.slug })),
63
+ initialData: async ({ slug }) => loadGuide(slug),
64
+ },
65
+ title: ({ slug }) => guides.find((g) => g.slug === slug)?.title ?? slug,
66
+ head: ({ slug }, seed) => ({
67
+ description: seed?.summary ?? "",
68
+ }),
69
+ view: (ctx) => html`<article>${ctx.data.body}</article>`,
70
+ });
71
+ ```
72
+
73
+ The seed produced by `initialData()` is JSON-serialised into the
74
+ captured HTML as `<script type="application/json" data-mado-static-data="…">`.
75
+ On first client boot Mado consumes it once so `page.load()` and
76
+ `page.head()` see the same data the snapshot saw — no extra fetch.
77
+
78
+ ## Running
79
+
80
+ In normal use `mado static` is invoked through `mado release`:
81
+
82
+ ```bash
83
+ mado release # vite build + mado static + deployment files
84
+ mado preview # serve out/ like a real static host
85
+ ```
86
+
87
+ You can also invoke `mado static` on its own (for tighter loops in
88
+ CI), but it operates on the existing `out/` artefact produced by
89
+ `vite build`, so the canonical command is `release`.
90
+
91
+ A public origin is required so static documents carry absolute
92
+ canonical URLs. Set it in `vite.config.ts`:
93
+
94
+ ```ts
95
+ import { defineConfig } from "vite";
96
+ import { mado } from "@madojs/mado/vite";
97
+
98
+ export default defineConfig({
99
+ plugins: [mado({ site: "https://your-app.example" })],
100
+ });
101
+ ```
102
+
103
+ …or override per run:
104
+
105
+ ```bash
106
+ mado release --base-url https://staging.example
107
+ MADO_SITE=https://staging.example mado release
108
+ ```
109
+
110
+ ## What the generated HTML contains
111
+
112
+ For every captured route:
113
+
114
+ - The full rendered light DOM.
115
+ - Every open shadow root serialised through Declarative Shadow DOM
116
+ (`<template shadowrootmode="open">`).
117
+ - Adopted stylesheets materialised into `<style>` tags inside the
118
+ shadow root.
119
+ - A `<link rel="canonical">` and `<meta property="og:url">` derived
120
+ from `site + base + pathname` (if the page did not declare its own).
121
+ Both are marked `data-mado-head="static"` so the runtime `applyHead`
122
+ removes them on SPA navigation into a page without explicit head
123
+ metadata.
124
+ - The seed `<script type="application/json" data-mado-static-data="…">`
125
+ for routes that declared `initialData()`.
126
+ - Vite assets resolved through the active `base`.
127
+
128
+ `<form>`, `<input>`, `<select>`, `<textarea>` and `<details>` state
129
+ is serialised through attributes so a JS-disabled browser sees the
130
+ same shape the snapshot captured.
131
+
132
+ ## Constraints and gotchas
133
+
134
+ - **`paths()` and `initialData()` run during discovery AND ship in the
135
+ client bundle.** Keep them browser-safe. Never read secrets, never
136
+ call private services.
137
+ - **Static pages cannot use guards** — route-level or layout-level.
138
+ - **Wildcard routes (`*`) cannot be static.** They are the SPA
139
+ fallback.
140
+ - **A compatible Chromium is required.** CI should install it through
141
+ Playwright:
142
+ ```bash
143
+ npx playwright install --with-deps chromium
144
+ ```
145
+ …or via the framework's installed `playwright-core` for revision
146
+ parity:
147
+ ```bash
148
+ node node_modules/playwright-core/cli.js install --with-deps chromium
149
+ ```
150
+ - **Capture is strict by default.** Any failed fetch (script, style,
151
+ resource(), custom element definition) fails the snapshot. A small
152
+ allow-list covers `/favicon.ico`, `/favicon.svg`, `/robots.txt`
153
+ and `data:` URLs.
154
+ - **All network calls happen against an internal capture server** that
155
+ serves the freshly built `out/` tree. There is no real network at
156
+ capture time.
157
+
158
+ ## Comparison
159
+
160
+ | Approach | Mado static snapshot |
161
+ | ------------------------------------- | -------------------- |
162
+ | Next-style SSR + hydration | not used |
163
+ | Astro-style island runtime | not used |
164
+ | Build-time HTML from templates only | not used |
165
+ | Browser-rendered DOM + DSD + takeover | **used** |
166
+
167
+ The result is a plain HTML file. Search engines, social preview
168
+ crawlers and `curl` see exactly what your app renders on first paint;
169
+ no JavaScript step is required to make the document meaningful.
170
+
171
+ ## Cookbook
172
+
173
+ Concrete recipes for blog, product catalogue, documentation site and
174
+ multi-locale builds live in [23-cookbook.md](./23-cookbook.md).
175
+
176
+ ## TL;DR
177
+
178
+ - Declare `static: true | { paths, initialData }` on `page({ ... })`.
179
+ - Run `mado release`.
180
+ - Ship `out/` to any static host.
181
+ - The live SPA takes over atomically on first paint.