@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.
- package/AGENTS.md +102 -25
- package/CHANGELOG.md +185 -1
- package/README.md +137 -172
- package/dist/src/component.d.ts +3 -2
- package/dist/src/component.js +98 -6
- package/dist/src/component.js.map +1 -1
- package/dist/src/css.js +34 -5
- package/dist/src/css.js.map +1 -1
- package/dist/src/head.d.ts +6 -6
- package/dist/src/head.js +6 -6
- package/dist/src/html/bindings.js +3 -3
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/parser.d.ts +1 -1
- package/dist/src/html/parser.js +1 -1
- package/dist/src/html/template.js +24 -10
- package/dist/src/html/template.js.map +1 -1
- package/dist/src/index.d.ts +8 -4
- package/dist/src/index.js +13 -3
- package/dist/src/index.js.map +1 -1
- package/dist/src/page.d.ts +62 -28
- package/dist/src/page.js +5 -0
- package/dist/src/page.js.map +1 -1
- package/dist/src/resource.js +2 -1
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/base.d.ts +65 -0
- package/dist/src/router/base.js +168 -0
- package/dist/src/router/base.js.map +1 -0
- package/dist/src/router/manifest.js +75 -31
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/match.d.ts +16 -2
- package/dist/src/router/match.js +41 -1
- package/dist/src/router/match.js.map +1 -1
- package/dist/src/router/navigation.js +58 -9
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/static-runtime.d.ts +81 -0
- package/dist/src/static-runtime.js +209 -0
- package/dist/src/static-runtime.js.map +1 -0
- package/dist/src/vite/index.d.ts +31 -3
- package/dist/src/vite/index.js +49 -18
- package/dist/src/vite/index.js.map +1 -1
- package/docs/README.md +5 -8
- package/docs/architecture/adr/0001-browser-static-snapshots.md +132 -0
- package/docs/en/00-the-mado-way.md +1 -1
- package/docs/en/01-quickstart.md +183 -0
- package/docs/en/10-pages-and-components.md +271 -0
- package/docs/en/11-templates-and-signals.md +211 -0
- package/docs/en/12-routing.md +229 -0
- package/docs/en/13-data.md +225 -0
- package/docs/en/14-forms.md +244 -0
- package/docs/en/15-static-snapshots.md +181 -0
- package/docs/en/{10-app-architecture.md → 16-app-architecture.md} +53 -2
- package/docs/en/{13-deployment.md → 20-deployment.md} +59 -10
- package/docs/en/{14-testing.md → 22-testing.md} +6 -5
- package/docs/en/23-cookbook.md +292 -0
- package/docs/en/{18-api-freeze-map.md → 30-api-freeze-map.md} +12 -3
- package/docs/en/{20-v1-stability.md → 32-v1-stability.md} +3 -3
- package/docs/en/40-llm-guide.md +776 -0
- package/docs/en/{06-for-backenders.md → 41-for-backenders.md} +15 -16
- package/docs/en/{05-why-mado.md → 42-why-mado.md} +1 -1
- package/docs/en/README.md +97 -27
- package/docs/recipes/nginx/Containerfile +26 -0
- package/docs/recipes/nginx/nginx.conf +42 -0
- package/llms.txt +246 -211
- package/package.json +17 -10
- package/scripts/bake.mjs +4 -568
- package/scripts/cli/generate.mjs +55 -5
- package/scripts/cli/help.mjs +11 -5
- package/scripts/cli/index.mjs +20 -4
- package/scripts/cli/init.mjs +7 -2
- package/scripts/cli/release.mjs +20 -68
- package/scripts/docs-lint.mjs +170 -0
- package/scripts/package-smoke.mjs +67 -9
- package/scripts/preview.mjs +95 -6
- package/scripts/size-budget.mjs +5 -1
- package/scripts/static/browser.mjs +654 -0
- package/scripts/static/discover.mjs +281 -0
- package/scripts/static/output.mjs +141 -0
- package/scripts/static/serialize.mjs +212 -0
- package/scripts/static/server.mjs +167 -0
- package/scripts/static.mjs +191 -0
- package/starters/default/README.md +57 -55
- package/starters/default/package.json +3 -9
- package/starters/default/src/app.routes.ts +20 -33
- package/starters/default/src/components/feature-card.component.ts +40 -0
- package/starters/default/src/components/live-counter.component.ts +57 -0
- package/starters/default/src/content/guides.ts +55 -0
- package/starters/default/src/main.ts +9 -13
- package/starters/default/src/pages/app.page.ts +41 -0
- package/starters/default/src/pages/guide.page.ts +40 -0
- package/starters/default/src/pages/home.page.ts +49 -0
- package/starters/default/src/pages/not-found.page.ts +18 -0
- package/starters/default/src/styles/document.css +39 -0
- package/starters/default/src/styles/reset.css +37 -0
- package/starters/default/src/styles/tokens.css +30 -0
- package/starters/default/src/styles.d.ts +3 -1
- package/starters/default/src/vite-env.d.ts +1 -1
- package/starters/default/vite.config.ts +14 -1
- package/starters/modular/README.md +142 -0
- package/starters/modular/index.html +13 -0
- package/starters/modular/package.json +30 -0
- package/starters/modular/src/app.routes.ts +39 -0
- package/starters/{default → modular}/src/layouts/app-shell.layout.ts +8 -5
- package/starters/{default → modular}/src/layouts/auth-shell.layout.ts +3 -3
- package/starters/modular/src/main.ts +16 -0
- package/starters/{default → modular}/src/modules/billing/pages/invoices-list.page.ts +3 -3
- package/starters/{default → modular}/src/modules/home/home.page.ts +5 -7
- package/starters/modular/src/modules/home/not-found.page.ts +14 -0
- package/starters/modular/src/styles.d.ts +1 -0
- package/starters/modular/src/vite-env.d.ts +1 -0
- package/starters/modular/tsconfig.json +24 -0
- package/starters/modular/vite.config.ts +131 -0
- package/TODO.md +0 -79
- package/docs/en/01-routing.md +0 -152
- package/docs/en/02-project-layout.md +0 -124
- package/docs/en/03-static-bake.md +0 -249
- package/docs/en/04-ide-setup.md +0 -162
- package/docs/en/07-llm-pitfalls.md +0 -724
- package/docs/en/08-llm-zero-history-test.md +0 -56
- package/docs/en/09-shadow-vs-light-dom.md +0 -174
- package/docs/en/11-layouts.md +0 -113
- package/docs/en/12-auth-and-api.md +0 -124
- package/docs/en/16-bake-cookbook.md +0 -100
- package/docs/en/17-shadow-dom-forms.md +0 -192
- package/docs/fr/00-the-mado-way.md +0 -85
- package/docs/fr/01-routing.md +0 -120
- package/docs/fr/02-project-layout.md +0 -84
- package/docs/fr/03-static-bake.md +0 -289
- package/docs/fr/04-ide-setup.md +0 -162
- package/docs/fr/05-why-mado.md +0 -193
- package/docs/fr/06-for-backenders.md +0 -438
- package/docs/fr/07-llm-pitfalls.md +0 -625
- package/docs/fr/08-llm-zero-history-test.md +0 -38
- package/docs/fr/09-shadow-vs-light-dom.md +0 -65
- package/docs/fr/10-app-architecture.md +0 -138
- package/docs/fr/11-layouts.md +0 -47
- package/docs/fr/12-auth-and-api.md +0 -76
- package/docs/fr/13-deployment.md +0 -57
- package/docs/fr/14-testing.md +0 -41
- package/docs/fr/15-error-handling.md +0 -50
- package/docs/fr/16-bake-cookbook.md +0 -88
- package/docs/fr/17-shadow-dom-forms.md +0 -196
- package/docs/fr/18-api-freeze-map.md +0 -63
- package/docs/fr/19-reactivity-ordering.md +0 -97
- package/docs/fr/20-v1-stability.md +0 -88
- package/docs/fr/README.md +0 -27
- package/docs/ru/00-the-mado-way.md +0 -84
- package/docs/ru/01-routing.md +0 -119
- package/docs/ru/02-project-layout.md +0 -84
- package/docs/ru/03-static-bake.md +0 -250
- package/docs/ru/04-ide-setup.md +0 -144
- package/docs/ru/05-why-mado.md +0 -193
- package/docs/ru/06-for-backenders.md +0 -428
- package/docs/ru/07-llm-pitfalls.md +0 -624
- package/docs/ru/08-llm-zero-history-test.md +0 -57
- package/docs/ru/09-shadow-vs-light-dom.md +0 -63
- package/docs/ru/10-app-architecture.md +0 -152
- package/docs/ru/11-layouts.md +0 -47
- package/docs/ru/12-auth-and-api.md +0 -75
- package/docs/ru/13-deployment.md +0 -66
- package/docs/ru/14-testing.md +0 -50
- package/docs/ru/15-error-handling.md +0 -56
- package/docs/ru/16-bake-cookbook.md +0 -95
- package/docs/ru/17-shadow-dom-forms.md +0 -193
- package/docs/ru/18-api-freeze-map.md +0 -64
- package/docs/ru/19-reactivity-ordering.md +0 -95
- package/docs/ru/20-v1-stability.md +0 -82
- package/docs/ru/README.md +0 -25
- package/docs/uk/00-the-mado-way.md +0 -82
- package/docs/uk/01-routing.md +0 -76
- package/docs/uk/02-project-layout.md +0 -73
- package/docs/uk/03-static-bake.md +0 -48
- package/docs/uk/04-ide-setup.md +0 -26
- package/docs/uk/05-why-mado.md +0 -34
- package/docs/uk/06-for-backenders.md +0 -55
- package/docs/uk/07-llm-pitfalls.md +0 -145
- package/docs/uk/08-llm-zero-history-test.md +0 -34
- package/docs/uk/09-shadow-vs-light-dom.md +0 -58
- package/docs/uk/10-app-architecture.md +0 -97
- package/docs/uk/11-layouts.md +0 -47
- package/docs/uk/12-auth-and-api.md +0 -70
- package/docs/uk/13-deployment.md +0 -40
- package/docs/uk/14-testing.md +0 -34
- package/docs/uk/15-error-handling.md +0 -32
- package/docs/uk/16-bake-cookbook.md +0 -36
- package/docs/uk/17-shadow-dom-forms.md +0 -193
- package/docs/uk/18-api-freeze-map.md +0 -61
- package/docs/uk/19-reactivity-ordering.md +0 -95
- package/docs/uk/20-v1-stability.md +0 -83
- package/docs/uk/README.md +0 -27
- package/starters/default/src/modules/home/not-found.page.ts +0 -11
- /package/docs/en/{15-error-handling.md → 21-error-handling.md} +0 -0
- /package/docs/en/{19-reactivity-ordering.md → 31-reactivity-ordering.md} +0 -0
- /package/starters/{default → modular}/.editorconfig +0 -0
- /package/starters/{default → modular}/eslint.config.mjs +0 -0
- /package/starters/{default → modular}/public/favicon.svg +0 -0
- /package/starters/{default → modular}/src/modules/auth/_contracts/auth-api.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.connector.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.guard.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.public.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.routes.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.service.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/auth.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/auth/login.page.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/_contracts/stripe.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/api/stripe.connector.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.public.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.routes.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/billing.types.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/components/invoice-status-badge.component.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/data/invoices.resource.ts +0 -0
- /package/starters/{default → modular}/src/modules/billing/pages/invoice-detail.page.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/http-client.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/http-error.ts +0 -0
- /package/starters/{default → modular}/src/shared/http/interceptors.ts +0 -0
- /package/starters/{default → modular}/src/shared/lib/format-date.ts +0 -0
- /package/starters/{default → modular}/src/shared/styles/content.css +0 -0
- /package/starters/{default → modular}/src/shared/styles/reset.css +0 -0
- /package/starters/{default → modular}/src/shared/styles/shell.css +0 -0
- /package/starters/{default → modular}/src/shared/styles/tokens.css +0 -0
- /package/starters/{default → modular}/src/shared/ui/x-button.component.ts +0 -0
- /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.
|