@madojs/mado 0.8.0 → 0.9.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 +29 -3
- package/CHANGELOG.md +112 -1
- package/README.md +168 -242
- package/ROADMAP.md +174 -79
- package/TODO.md +8 -5
- package/dist/src/component.js +28 -0
- package/dist/src/component.js.map +1 -1
- package/dist/src/forms.js +17 -0
- package/dist/src/forms.js.map +1 -1
- package/dist/src/html/bindings.js +32 -3
- package/dist/src/html/bindings.js.map +1 -1
- package/dist/src/html/parser.js +60 -3
- package/dist/src/html/parser.js.map +1 -1
- package/dist/src/lifecycle.js +18 -0
- package/dist/src/lifecycle.js.map +1 -1
- package/dist/src/persisted.js +43 -9
- package/dist/src/persisted.js.map +1 -1
- package/dist/src/resource.d.ts +10 -0
- package/dist/src/resource.js +24 -6
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.js +22 -2
- package/dist/src/router/manifest.js.map +1 -1
- package/dist/src/router/navigation.js +56 -2
- package/dist/src/router/navigation.js.map +1 -1
- package/dist/src/signal.js +55 -7
- package/dist/src/signal.js.map +1 -1
- package/docs/en/00-the-mado-way.md +23 -12
- package/docs/en/05-why-mado.md +78 -68
- package/docs/en/06-for-backenders.md +75 -55
- package/docs/en/07-llm-pitfalls.md +101 -0
- package/docs/fr/00-the-mado-way.md +25 -13
- package/docs/fr/07-llm-pitfalls.md +2 -0
- package/docs/ru/00-the-mado-way.md +24 -11
- package/docs/ru/07-llm-pitfalls.md +2 -0
- package/docs/uk/00-the-mado-way.md +3 -1
- package/docs/uk/07-llm-pitfalls.md +2 -0
- package/llms.txt +6 -4
- package/package.json +3 -3
- package/scripts/bundle.mjs +6 -6
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Mado for Backend Developers
|
|
2
2
|
|
|
3
|
-
> You write in Go / Rust / .NET / Java / Python and you need to build a web UI
|
|
3
|
+
> You write in Go / Rust / .NET / Java / Python and you need to build a web UI
|
|
4
|
+
> for an admin panel, internal tool or dashboard.
|
|
4
5
|
> This page is the mental model of Mado in 10 minutes, in your language.
|
|
5
6
|
|
|
6
7
|
---
|
|
@@ -9,17 +10,17 @@
|
|
|
9
10
|
|
|
10
11
|
Mado is structured **like an HTTP server**. Seriously:
|
|
11
12
|
|
|
12
|
-
| Server world
|
|
13
|
-
|
|
14
|
-
| HTTP router (chi, axum, mux)
|
|
15
|
-
| Handler `func(req, resp)`
|
|
16
|
-
| Middleware
|
|
17
|
-
| Template engine (Jinja, Handlebars) | `html\`\`` tagged template
|
|
18
|
-
| HTTP client with cache
|
|
19
|
-
| Reactive variable / atom
|
|
20
|
-
| Background goroutine / task
|
|
21
|
-
| `defer cleanup()`
|
|
22
|
-
| ENV variables
|
|
13
|
+
| Server world | Mado |
|
|
14
|
+
| ----------------------------------- | ---------------------------------------------- |
|
|
15
|
+
| HTTP router (chi, axum, mux) | `routes()` — path manifest |
|
|
16
|
+
| Handler `func(req, resp)` | `page({ view: (ctx) => html\`...\` })` |
|
|
17
|
+
| Middleware | `layout` in `nested()` (wraps the handler) |
|
|
18
|
+
| Template engine (Jinja, Handlebars) | `html\`\`` tagged template |
|
|
19
|
+
| HTTP client with cache | `resource()` — fetch + cache + invalidation |
|
|
20
|
+
| Reactive variable / atom | `signal()` — reactive getter |
|
|
21
|
+
| Background goroutine / task | `effect()` — auto-reruns when a signal changes |
|
|
22
|
+
| `defer cleanup()` | `ctx.onDispose(fn)` in component setup |
|
|
23
|
+
| ENV variables | `createContext()` + `provide()`/`inject()` |
|
|
23
24
|
|
|
24
25
|
If you understand an HTTP server, you understand Mado.
|
|
25
26
|
|
|
@@ -130,20 +131,23 @@ import { resource, mutation, jsonFetcher, invalidate } from "@madojs/mado";
|
|
|
130
131
|
const userId = signal(1);
|
|
131
132
|
|
|
132
133
|
const user = resource(
|
|
133
|
-
() => `/api/users/${userId()}`,
|
|
134
|
-
jsonFetcher<User>(),
|
|
135
|
-
{ staleTime: 60_000 },
|
|
134
|
+
() => `/api/users/${userId()}`, // cache key (reactive!)
|
|
135
|
+
jsonFetcher<User>(), // how to load
|
|
136
|
+
{ staleTime: 60_000 }, // 60-second cache
|
|
136
137
|
);
|
|
137
138
|
|
|
138
139
|
// in the component:
|
|
139
|
-
user.data();
|
|
140
|
-
user.error();
|
|
141
|
-
user.loading();
|
|
140
|
+
user.data(); // User | undefined
|
|
141
|
+
user.error(); // Error | null
|
|
142
|
+
user.loading(); // boolean
|
|
142
143
|
|
|
143
144
|
// mutation (like POST/PUT)
|
|
144
145
|
const save = mutation<User, User>(
|
|
145
|
-
(u) =>
|
|
146
|
-
|
|
146
|
+
(u) =>
|
|
147
|
+
fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
|
|
148
|
+
r.json(),
|
|
149
|
+
),
|
|
150
|
+
{ invalidates: ["/api/users*"] }, // glob invalidation — like `cache.Drop("users:*")`
|
|
147
151
|
);
|
|
148
152
|
|
|
149
153
|
await save.run(newUser);
|
|
@@ -169,9 +173,7 @@ component("x-counter", () => {
|
|
|
169
173
|
const count = signal(0);
|
|
170
174
|
|
|
171
175
|
return () => html`
|
|
172
|
-
<button @click=${() => count.update(n => n + 1)}>
|
|
173
|
-
Clicks: ${count}
|
|
174
|
-
</button>
|
|
176
|
+
<button @click=${() => count.update((n) => n + 1)}>Clicks: ${count}</button>
|
|
175
177
|
`;
|
|
176
178
|
});
|
|
177
179
|
```
|
|
@@ -179,7 +181,7 @@ component("x-counter", () => {
|
|
|
179
181
|
Usage:
|
|
180
182
|
|
|
181
183
|
```ts
|
|
182
|
-
html`<x-counter></x-counter
|
|
184
|
+
html`<x-counter></x-counter>`;
|
|
183
185
|
```
|
|
184
186
|
|
|
185
187
|
We register the `<x-counter>` tag in the browser — it becomes a "function" that can be inserted into HTML. This is a **native** browser mechanism (Web Components), Mado only glues it together with signals.
|
|
@@ -195,22 +197,29 @@ import { useForm } from "@madojs/mado";
|
|
|
195
197
|
|
|
196
198
|
const f = useForm({
|
|
197
199
|
email: { required: true, type: "email" },
|
|
198
|
-
age:
|
|
200
|
+
age: { required: true, type: "number", min: 18 },
|
|
199
201
|
});
|
|
200
202
|
|
|
201
203
|
// in the template:
|
|
202
204
|
html`
|
|
203
|
-
<form
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
205
|
+
<form
|
|
206
|
+
@submit=${f.onSubmit(async (v) => {
|
|
207
|
+
await api.save(v);
|
|
208
|
+
f.reset();
|
|
209
|
+
})}
|
|
210
|
+
>
|
|
211
|
+
<input
|
|
212
|
+
name="email"
|
|
213
|
+
.value=${() => f.values().email ?? ""}
|
|
214
|
+
@input=${f.onInput}
|
|
215
|
+
@blur=${f.onBlur}
|
|
216
|
+
/>
|
|
217
|
+
|
|
218
|
+
${() =>
|
|
219
|
+
f.errors().email && f.touched().email
|
|
220
|
+
? html`<small>${f.errors().email}</small>`
|
|
221
|
+
: null}
|
|
222
|
+
|
|
214
223
|
<button ?disabled=${() => !f.isValid() || f.submitting()}>Save</button>
|
|
215
224
|
</form>
|
|
216
225
|
`;
|
|
@@ -233,12 +242,12 @@ const ApiCtx = createContext<ApiClient>(defaultApiClient);
|
|
|
233
242
|
// in the root component — provide
|
|
234
243
|
component("x-app", ({ host }) => {
|
|
235
244
|
provide(host, ApiCtx, new ApiClient("https://api.example.com"));
|
|
236
|
-
return () => html`<x-page/>`;
|
|
245
|
+
return () => html`<x-page />`;
|
|
237
246
|
});
|
|
238
247
|
|
|
239
248
|
// in any child — consume
|
|
240
249
|
component("x-page", ({ host }) => {
|
|
241
|
-
const api = inject(host, ApiCtx);
|
|
250
|
+
const api = inject(host, ApiCtx); // signal<ApiClient>
|
|
242
251
|
return () => html`<div>API version: ${() => api().version}</div>`;
|
|
243
252
|
});
|
|
244
253
|
```
|
|
@@ -255,7 +264,7 @@ If you're used to server-side rendering for SEO, in Mado this is solved differen
|
|
|
255
264
|
// src/pages/product.ts
|
|
256
265
|
export default page({
|
|
257
266
|
bake: {
|
|
258
|
-
paths: () => api.allProductSlugs(),
|
|
267
|
+
paths: () => api.allProductSlugs(), // build-time fetch
|
|
259
268
|
data: ({ slug }) => api.getProduct(slug),
|
|
260
269
|
revalidate: 3600,
|
|
261
270
|
},
|
|
@@ -264,7 +273,7 @@ export default page({
|
|
|
264
273
|
canonical: `/product/${slug}`,
|
|
265
274
|
og: { title: data.name, image: data.image },
|
|
266
275
|
}),
|
|
267
|
-
view: ({ params }) => html`<x-product data-slug=${params.slug}/>`,
|
|
276
|
+
view: ({ params }) => html`<x-product data-slug=${params.slug} />`,
|
|
268
277
|
});
|
|
269
278
|
```
|
|
270
279
|
|
|
@@ -288,14 +297,20 @@ import { page, html, resource, each, signal } from "@madojs/mado";
|
|
|
288
297
|
export default page({
|
|
289
298
|
view: () => {
|
|
290
299
|
const users = resource(() => "/api/users", jsonFetcher<User[]>());
|
|
291
|
-
|
|
300
|
+
|
|
292
301
|
return html`
|
|
293
|
-
${() => users.loading() ? html`<p>Loading…</p>` : null}
|
|
294
|
-
${() =>
|
|
302
|
+
${() => (users.loading() ? html`<p>Loading…</p>` : null)}
|
|
303
|
+
${() =>
|
|
304
|
+
users.error() ? html`<p>Error: ${users.error()!.message}</p>` : null}
|
|
295
305
|
<ul>
|
|
296
|
-
${() =>
|
|
297
|
-
|
|
298
|
-
|
|
306
|
+
${() =>
|
|
307
|
+
each(
|
|
308
|
+
users.data() ?? [],
|
|
309
|
+
(u) => u.id,
|
|
310
|
+
(u) => html`
|
|
311
|
+
<li><a href="/users/${u.id}" data-link>${u.name}</a></li>
|
|
312
|
+
`,
|
|
313
|
+
)}
|
|
299
314
|
</ul>
|
|
300
315
|
`;
|
|
301
316
|
},
|
|
@@ -308,7 +323,10 @@ export default page({
|
|
|
308
323
|
import { useForm, mutation } from "@madojs/mado";
|
|
309
324
|
|
|
310
325
|
const createUser = mutation<NewUser, User>(
|
|
311
|
-
(u) =>
|
|
326
|
+
(u) =>
|
|
327
|
+
fetch("/api/users", { method: "POST", body: JSON.stringify(u) }).then((r) =>
|
|
328
|
+
r.json(),
|
|
329
|
+
),
|
|
312
330
|
{ invalidates: ["/api/users*"] },
|
|
313
331
|
);
|
|
314
332
|
|
|
@@ -316,11 +334,13 @@ const createUser = mutation<NewUser, User>(
|
|
|
316
334
|
const f = useForm({ name: { required: true } });
|
|
317
335
|
|
|
318
336
|
html`
|
|
319
|
-
<form
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
337
|
+
<form
|
|
338
|
+
@submit=${f.onSubmit(async (v) => {
|
|
339
|
+
await createUser.run(v);
|
|
340
|
+
navigate("/users");
|
|
341
|
+
})}
|
|
342
|
+
>
|
|
343
|
+
<input name="name" @input=${f.onInput} />
|
|
324
344
|
<button>Create</button>
|
|
325
345
|
</form>
|
|
326
346
|
`;
|
|
@@ -353,8 +373,8 @@ export default routes({
|
|
|
353
373
|
"/app/*": nested({
|
|
354
374
|
layout: () => import("./layouts/auth-layout.js"),
|
|
355
375
|
routes: {
|
|
356
|
-
|
|
357
|
-
|
|
376
|
+
dashboard: () => import("./pages/dashboard.js"),
|
|
377
|
+
users: () => import("./pages/users.js"),
|
|
358
378
|
},
|
|
359
379
|
}),
|
|
360
380
|
});
|
|
@@ -367,7 +387,7 @@ export default routes({
|
|
|
367
387
|
export class ApiClient {
|
|
368
388
|
constructor(private base: string) {}
|
|
369
389
|
get<T>(path: string): Promise<T> {
|
|
370
|
-
return fetch(this.base + path).then(r => r.json());
|
|
390
|
+
return fetch(this.base + path).then((r) => r.json());
|
|
371
391
|
}
|
|
372
392
|
}
|
|
373
393
|
|
|
@@ -598,6 +598,105 @@ See [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md) for the full pattern.
|
|
|
598
598
|
|
|
599
599
|
---
|
|
600
600
|
|
|
601
|
+
## Pitfall #23: signal reads in async functions called from `view()` create effect cycles
|
|
602
|
+
|
|
603
|
+
**Symptom:** `[mado] effect cycle detected: subscriber re-ran more than 100 times in one flush.`
|
|
604
|
+
|
|
605
|
+
The router calls `page.view()` inside a reactive effect. Any signal read
|
|
606
|
+
**synchronously** during `view()` subscribes the router's render effect. If that
|
|
607
|
+
signal is then written (e.g. `loading.set(true)`) — the router re-runs `view()`,
|
|
608
|
+
which reads the signal again → infinite loop.
|
|
609
|
+
|
|
610
|
+
```ts
|
|
611
|
+
// ❌ INFINITE LOOP — loadMore reads signals inside the router's effect
|
|
612
|
+
export default page({
|
|
613
|
+
view: () => {
|
|
614
|
+
const cursor = signal<string | null>("start");
|
|
615
|
+
const loading = signal(false);
|
|
616
|
+
|
|
617
|
+
const loadMore = async () => {
|
|
618
|
+
if (cursor() === null || loading()) return; // ← subscribes render effect!
|
|
619
|
+
loading.set(true); // ← re-triggers render → loadMore() → ∞
|
|
620
|
+
const res = await fetch(`/api/items?cursor=${cursor()}`);
|
|
621
|
+
// ...
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
loadMore(); // called synchronously during view()
|
|
625
|
+
return html`...`;
|
|
626
|
+
},
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
// ✅ CORRECT — wrap signal reads in untracked()
|
|
630
|
+
import { untracked } from "@madojs/mado";
|
|
631
|
+
|
|
632
|
+
export default page({
|
|
633
|
+
view: () => {
|
|
634
|
+
const cursor = signal<string | null>("start");
|
|
635
|
+
const loading = signal(false);
|
|
636
|
+
|
|
637
|
+
const loadMore = async () => {
|
|
638
|
+
const c = untracked(() => cursor());
|
|
639
|
+
if (c === null || untracked(() => loading())) return;
|
|
640
|
+
loading.set(true);
|
|
641
|
+
const res = await fetch(`/api/items?cursor=${c}`);
|
|
642
|
+
// ...
|
|
643
|
+
};
|
|
644
|
+
|
|
645
|
+
loadMore();
|
|
646
|
+
return html`...`;
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
**Rule:** Any function that reads signals AND is called synchronously during
|
|
652
|
+
`view()` initialization must use `untracked()` for those reads. This includes:
|
|
653
|
+
|
|
654
|
+
- Data fetching / loadMore functions
|
|
655
|
+
- IntersectionObserver callbacks set up during init
|
|
656
|
+
- Timer/polling setup functions that check state
|
|
657
|
+
|
|
658
|
+
Signals read inside the **returned template** (`html\`...\``) are fine — they are
|
|
659
|
+
wrapped in a child-binding function `${() => ...}` which creates its own effect.
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## Pitfall #24: `setInterval` / manual subscriptions in `page()` view without cleanup
|
|
664
|
+
|
|
665
|
+
**Symptom:** After navigating away, timers/subscriptions keep running (zombie intervals,
|
|
666
|
+
server logs show polling requests from pages the user already left).
|
|
667
|
+
|
|
668
|
+
```ts
|
|
669
|
+
// ❌ ZOMBIE — interval survives navigation
|
|
670
|
+
export default page({
|
|
671
|
+
view: () => {
|
|
672
|
+
const tick = signal(0);
|
|
673
|
+
setInterval(() => tick.update((n) => n + 1), 3000); // never cleaned up!
|
|
674
|
+
return html`<div>${tick}</div>`;
|
|
675
|
+
},
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
// ✅ CORRECT — use onDispose for cleanup
|
|
679
|
+
export default page({
|
|
680
|
+
view: ({ onDispose }) => {
|
|
681
|
+
const tick = signal(0);
|
|
682
|
+
const id = setInterval(() => tick.update((n) => n + 1), 3000);
|
|
683
|
+
onDispose(() => clearInterval(id));
|
|
684
|
+
return html`<div>${tick}</div>`;
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
```
|
|
688
|
+
|
|
689
|
+
**Note:** `resource()` and `effect()` created inside `view()` are automatically
|
|
690
|
+
cleaned up on navigation (they register with the page lifecycle). Only raw
|
|
691
|
+
browser APIs need explicit `onDispose()`:
|
|
692
|
+
|
|
693
|
+
- `setInterval` / `setTimeout`
|
|
694
|
+
- `addEventListener` (on window/document)
|
|
695
|
+
- `WebSocket` / `EventSource`
|
|
696
|
+
- `IntersectionObserver` / `ResizeObserver`
|
|
697
|
+
|
|
698
|
+
---
|
|
699
|
+
|
|
601
700
|
## Cheat-sheet for AI
|
|
602
701
|
|
|
603
702
|
| If you want to do… | Correct in Mado |
|
|
@@ -619,5 +718,7 @@ See [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md) for the full pattern.
|
|
|
619
718
|
| `@customElement('x')` | `component('x-name', setup)` |
|
|
620
719
|
| `host.getAttribute('x')` in render | `ctx.attr('x', default)` (reactive) |
|
|
621
720
|
| `jsonFetcher()` with auth | `apiFetcher()` (attaches Bearer token) |
|
|
721
|
+
| `setInterval` in page view | `onDispose(() => clearInterval(id))` |
|
|
722
|
+
| signal read in view() async init | `untracked(() => cursor())` |
|
|
622
723
|
|
|
623
724
|
If something doesn't fit this list — open `src/` and **read 500 lines**. Seriously. Mado is intentionally small to be readable.
|
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
> Une seule bonne façon. Des contrats stricts. Pas de magie.
|
|
4
4
|
|
|
5
|
-
Mado
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
Mado est un framework pour les équipes qui construisent des panneaux d'admin,
|
|
6
|
+
des outils internes et des SPA métier — des apps qui doivent être simples à
|
|
7
|
+
créer et ennuyeuses à maintenir. Pour cela, il impose un **ensemble de
|
|
8
|
+
conventions**. Si vous les respectez, le projet reste compréhensible même avec
|
|
9
|
+
200 écrans et 5 développeurs. Si vous les enfreignez — les types et le linter
|
|
10
|
+
vous le diront immédiatement.
|
|
8
11
|
|
|
9
12
|
## Principes
|
|
10
13
|
|
|
@@ -42,13 +45,21 @@ tous écrire de la même manière.
|
|
|
42
45
|
|
|
43
46
|
```ts
|
|
44
47
|
// src/components/user-card.ts
|
|
45
|
-
import { component, html, css } from
|
|
46
|
-
|
|
47
|
-
component(
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
48
|
+
import { component, html, css } from "@madojs/mado";
|
|
49
|
+
|
|
50
|
+
component(
|
|
51
|
+
"x-user-card",
|
|
52
|
+
() => {
|
|
53
|
+
return () => html`<div class="card"><slot /></div>`;
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
styles: css`
|
|
57
|
+
.card {
|
|
58
|
+
padding: 1rem;
|
|
59
|
+
}
|
|
60
|
+
`,
|
|
61
|
+
},
|
|
62
|
+
);
|
|
52
63
|
```
|
|
53
64
|
|
|
54
65
|
`import './components/user-card.js'` **enregistre** le composant via
|
|
@@ -63,7 +74,7 @@ component('x-user-card', () => {
|
|
|
63
74
|
const user = resource(() => `/api/users/${id()}`, jsonFetcher());
|
|
64
75
|
|
|
65
76
|
// écriture → mutation
|
|
66
|
-
const save = mutation(api.save, { invalidates: [
|
|
77
|
+
const save = mutation(api.save, { invalidates: ["/api/users*"] });
|
|
67
78
|
```
|
|
68
79
|
|
|
69
80
|
Cela fournit la mise en cache, l'annulation, la gestion des erreurs et l'invalidation automatique.
|
|
@@ -72,11 +83,11 @@ Cela fournit la mise en cache, l'annulation, la gestion des erreurs et l'invalid
|
|
|
72
83
|
|
|
73
84
|
```ts
|
|
74
85
|
// src/pages/user-profile.ts
|
|
75
|
-
import { page, html, resource, jsonFetcher } from
|
|
86
|
+
import { page, html, resource, jsonFetcher } from "@madojs/mado";
|
|
76
87
|
|
|
77
88
|
export default page({
|
|
78
89
|
title: ({ id }) => `Utilisateur #${id}`,
|
|
79
|
-
view:
|
|
90
|
+
view: ({ params }) => html`...`,
|
|
80
91
|
});
|
|
81
92
|
```
|
|
82
93
|
|
|
@@ -101,6 +112,7 @@ Voir [`01-routing.md`](./01-routing.md).
|
|
|
101
112
|
## En cas de doute
|
|
102
113
|
|
|
103
114
|
Si vous vous demandez "quelle est la meilleure façon ici ?" — c'est un signal que :
|
|
115
|
+
|
|
104
116
|
1. Soit il existe un helper intégré que vous ne connaissez pas (consultez `docs/`).
|
|
105
117
|
2. Soit c'est une nouvelle situation — discutez-en et **consignez-la** dans ce document
|
|
106
118
|
comme une convention supplémentaire.
|
|
@@ -619,5 +619,7 @@ Plus de détails : [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
|
|
|
619
619
|
| `@customElement('x')` | `component('x-name', setup)` |
|
|
620
620
|
| `host.getAttribute('x')` dans render | `ctx.attr('x', default)` (réactif) |
|
|
621
621
|
| `jsonFetcher()` avec auth | `apiFetcher()` (attache le Bearer token) |
|
|
622
|
+
| `setInterval` dans page view | `onDispose(() => clearInterval(id))` |
|
|
623
|
+
| lecture signal dans async init view() | `untracked(() => cursor())` |
|
|
622
624
|
|
|
623
625
|
Si quelque chose ne rentre pas dans cette liste — ouvrez `src/` et **lisez 500 lignes**. Sérieusement. Mado est intentionnellement petit pour être lisible.
|
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
> Один правильный путь. Жёсткие контракты. Никакой магии.
|
|
4
4
|
|
|
5
|
-
Mado —
|
|
5
|
+
Mado — фреймворк для команд, которые строят админки, внутренние инструменты
|
|
6
|
+
и бизнес-SPA — приложения, которые должны быть просты в разработке и скучны
|
|
7
|
+
в поддержке. Для этого он задаёт **набор соглашений**. Если ты следуешь им,
|
|
8
|
+
проект остаётся понятным даже когда в нём 200 экранов и 5 разработчиков. Если
|
|
9
|
+
нарушаешь — типы и линтер скажут об этом сразу.
|
|
6
10
|
|
|
7
11
|
## Принципы
|
|
8
12
|
|
|
@@ -32,13 +36,21 @@ src/
|
|
|
32
36
|
|
|
33
37
|
```ts
|
|
34
38
|
// src/components/user-card.ts
|
|
35
|
-
import { component, html, css } from
|
|
36
|
-
|
|
37
|
-
component(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
}
|
|
39
|
+
import { component, html, css } from "@madojs/mado";
|
|
40
|
+
|
|
41
|
+
component(
|
|
42
|
+
"x-user-card",
|
|
43
|
+
() => {
|
|
44
|
+
return () => html`<div class="card"><slot /></div>`;
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
styles: css`
|
|
48
|
+
.card {
|
|
49
|
+
padding: 1rem;
|
|
50
|
+
}
|
|
51
|
+
`,
|
|
52
|
+
},
|
|
53
|
+
);
|
|
42
54
|
```
|
|
43
55
|
|
|
44
56
|
Импорт `import './components/user-card.js'` **регистрирует** компонент через `customElements.define`. Это side-effect. Где компонент нужен — там и импортируем.
|
|
@@ -52,7 +64,7 @@ component('x-user-card', () => {
|
|
|
52
64
|
const user = resource(() => `/api/users/${id()}`, jsonFetcher());
|
|
53
65
|
|
|
54
66
|
// запись → mutation
|
|
55
|
-
const save = mutation(api.save, { invalidates: [
|
|
67
|
+
const save = mutation(api.save, { invalidates: ["/api/users*"] });
|
|
56
68
|
```
|
|
57
69
|
|
|
58
70
|
Это даёт кеш, отмену, обработку ошибок, авто-инвалидацию.
|
|
@@ -61,11 +73,11 @@ const save = mutation(api.save, { invalidates: ['/api/users*'] });
|
|
|
61
73
|
|
|
62
74
|
```ts
|
|
63
75
|
// src/pages/user-profile.ts
|
|
64
|
-
import { page, html, resource, jsonFetcher } from
|
|
76
|
+
import { page, html, resource, jsonFetcher } from "@madojs/mado";
|
|
65
77
|
|
|
66
78
|
export default page({
|
|
67
79
|
title: ({ id }) => `User #${id}`,
|
|
68
|
-
view:
|
|
80
|
+
view: ({ params }) => html`...`,
|
|
69
81
|
});
|
|
70
82
|
```
|
|
71
83
|
|
|
@@ -87,6 +99,7 @@ export default page({
|
|
|
87
99
|
## Когда сомневаешься
|
|
88
100
|
|
|
89
101
|
Если ты задаёшься вопросом "а как тут лучше?" — это сигнал, что:
|
|
102
|
+
|
|
90
103
|
1. Либо есть встроенный хелпер, который ты не знаешь (загляни в `docs/`).
|
|
91
104
|
2. Либо это новая ситуация — её надо обсудить и **зафиксировать** в этом документе как ещё одно соглашение.
|
|
92
105
|
|
|
@@ -618,5 +618,7 @@ component("x-input", ({ host, attr }) => {
|
|
|
618
618
|
| `@customElement('x')` | `component('x-name', setup)` |
|
|
619
619
|
| `host.getAttribute('x')` в render | `ctx.attr('x', default)` (реактивно) |
|
|
620
620
|
| `jsonFetcher()` с авторизацией | `apiFetcher()` (прикрепляет Bearer токен) |
|
|
621
|
+
| `setInterval` в page view | `onDispose(() => clearInterval(id))` |
|
|
622
|
+
| чтение сигнала в async init view() | `untracked(() => cursor())` |
|
|
621
623
|
|
|
622
624
|
Если что-то не подходит из этого списка — открой `src/` и **прочитай 500 строк**. Это серьёзно. Mado специально маленький, чтобы быть читаемым.
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
> Один зрозумілий шлях. Жорсткі контракти. Мінімум магії.
|
|
4
4
|
|
|
5
|
-
Mado —
|
|
5
|
+
Mado — фреймворк для команд, що будують адмін-панелі, внутрішні інструменти
|
|
6
|
+
та бізнес-SPA — застосунки, які мають бути простими у розробці та нудними в
|
|
7
|
+
підтримці. Для цього він задає **набір домовленостей**. Якщо їх дотримуватись,
|
|
6
8
|
проєкт залишається читабельним навіть тоді, коли в ньому десятки сторінок і
|
|
7
9
|
кілька розробників.
|
|
8
10
|
|
|
@@ -141,3 +141,5 @@ Object.defineProperty(host, "value", {
|
|
|
141
141
|
| `class extends HTMLElement` | `component('x-name', setup)` |
|
|
142
142
|
| `host.getAttribute('x')` | `ctx.attr('x', default)` |
|
|
143
143
|
| `jsonFetcher()` з auth | `apiFetcher()` |
|
|
144
|
+
| `setInterval` в page view | `onDispose(() => clearInterval(id))` |
|
|
145
|
+
| читання сигналу в async init | `untracked(() => cursor())` |
|
package/llms.txt
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Mado
|
|
2
2
|
|
|
3
|
-
>
|
|
4
|
-
>
|
|
3
|
+
> A calm browser-native SPA framework for internal tools, admin panels and business apps.
|
|
4
|
+
> Routing, forms, state, data fetching and prerendering. TypeScript-only build (`tsc`), zero runtime dependencies.
|
|
5
5
|
|
|
6
|
-
Mado is a
|
|
6
|
+
Mado is a focused frontend framework for admin panels, internal tools and CRUD-heavy SPA. It deliberately avoids React patterns (no JSX, no hooks, no VDOM, no Vite). Target audience: backend developers and small teams who want a complete app stack without frontend infrastructure overhead.
|
|
7
7
|
|
|
8
8
|
## Key things an AI assistant needs to know
|
|
9
9
|
|
|
@@ -13,7 +13,9 @@ Mado is a narrowly-focused frontend framework that deliberately avoids React pat
|
|
|
13
13
|
- **Components are Web Components.** Registered via `component('x-name', setupFn, options)`. Names must include a hyphen (`x-foo`, `my-button`).
|
|
14
14
|
- **Component files register tags as side effects.** The browser does not auto-import files by tag name. If `<x-card>` works, some imported module already ran `customElements.define("x-card", ...)`.
|
|
15
15
|
- **Cleanup via `ctx.onDispose(fn)`** in setup, not via return from effect.
|
|
16
|
-
- **
|
|
16
|
+
- **Page cleanup via `onDispose`** in view: `view: ({ onDispose }) => { ... onDispose(() => cleanup()); }`. Only needed for raw APIs (setInterval, WebSocket). `resource()`/`effect()` auto-cleanup.
|
|
17
|
+
- **`untracked()` in page view async** — functions called synchronously in `view()` that read signals must wrap reads in `untracked()` to avoid effect cycles with the router.
|
|
18
|
+
- **Reactive attributes via `ctx.attr(name, default?)`** — returns a Signal<string> that auto-updates when the attribute changes. Uses `observedAttributes` when available, falls back to a per-instance `MutationObserver` for attrs registered during setup. No boilerplate needed.
|
|
17
19
|
|
|
18
20
|
## Critical template rules
|
|
19
21
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@madojs/mado",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Mado — a
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Mado — a calm browser-native SPA framework for internal tools, admin panels and business apps. Routing, forms, state and data fetching without frontend infrastructure overhead.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
@@ -80,4 +80,4 @@
|
|
|
80
80
|
"engines": {
|
|
81
81
|
"node": ">=20"
|
|
82
82
|
}
|
|
83
|
-
}
|
|
83
|
+
}
|
package/scripts/bundle.mjs
CHANGED
|
@@ -120,14 +120,14 @@ const result = await build({
|
|
|
120
120
|
legalComments: "none",
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
123
|
+
// With splitting: true, esbuild marks all dynamic-import chunks as having
|
|
124
|
+
// entryPoint. We identify the real app entry by the `entryNames` prefix "main-".
|
|
125
|
+
const mainBundle = (await readdir(ASSETS_DIR))
|
|
126
|
+
.find((f) => f.startsWith("main-") && f.endsWith(".js") && !f.endsWith(".js.map"));
|
|
127
|
+
if (!mainBundle) {
|
|
128
|
+
console.error("[bundle] entry not found in outputs (no main-*.js in assets dir)");
|
|
128
129
|
process.exit(1);
|
|
129
130
|
}
|
|
130
|
-
const mainBundle = basename(entryOutput[0]);
|
|
131
131
|
|
|
132
132
|
// Collect all js chunks in the assets dir.
|
|
133
133
|
const allJs = (await readdir(ASSETS_DIR)).filter((f) => f.endsWith(".js"));
|