@madojs/mado 0.6.0 → 0.7.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 +82 -30
- package/CHANGELOG.md +208 -1
- package/dist/src/component.d.ts +17 -4
- package/dist/src/component.js +26 -4
- package/dist/src/component.js.map +1 -1
- package/dist/src/resource.js +11 -0
- package/dist/src/resource.js.map +1 -1
- package/dist/src/router/manifest.js +29 -2
- package/dist/src/router/manifest.js.map +1 -1
- package/docs/en/07-llm-pitfalls.md +197 -60
- package/docs/en/08-llm-zero-history-test.md +1 -1
- package/docs/en/17-shadow-dom-forms.md +192 -0
- package/docs/en/README.md +20 -19
- package/docs/fr/07-llm-pitfalls.md +196 -60
- package/docs/fr/17-shadow-dom-forms.md +196 -0
- package/docs/fr/README.md +20 -19
- package/docs/ru/07-llm-pitfalls.md +198 -61
- package/docs/ru/08-llm-zero-history-test.md +39 -38
- package/docs/ru/09-shadow-vs-light-dom.md +97 -81
- package/docs/ru/17-shadow-dom-forms.md +193 -0
- package/docs/ru/README.md +20 -19
- package/docs/uk/07-llm-pitfalls.md +64 -3
- package/docs/uk/17-shadow-dom-forms.md +193 -0
- package/docs/uk/README.md +20 -19
- package/llms.txt +50 -1
- package/package.json +2 -2
- package/scripts/bake.mjs +76 -22
- package/scripts/bundle.mjs +24 -1
- package/scripts/cli.mjs +98 -45
- package/scripts/preview.mjs +104 -10
- package/server/serve.mjs +80 -7
- package/starters/admin/index.html +10 -3
- package/starters/admin/package.json +3 -1
- package/starters/admin/src/components/x-button.ts +40 -13
- package/starters/admin/src/components/x-input.ts +50 -19
- package/starters/admin/src/lib/api.ts +55 -4
- package/starters/admin/src/pages/admin/order-detail.ts +4 -2
- package/starters/admin/src/pages/home.ts +10 -1
- package/starters/crud/index.html +12 -4
- package/starters/crud/package.json +3 -1
- package/starters/crud/src/pages/home.ts +16 -0
- package/starters/minimal/index.html +12 -4
- package/starters/minimal/package.json +2 -0
- package/starters/minimal/src/pages/home.ts +17 -0
|
@@ -27,10 +27,10 @@ count.set(1);
|
|
|
27
27
|
|
|
28
28
|
```ts
|
|
29
29
|
// Ні
|
|
30
|
-
count.value
|
|
30
|
+
count.value;
|
|
31
31
|
|
|
32
32
|
// Так
|
|
33
|
-
count()
|
|
33
|
+
count();
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
## Нереактивний child binding
|
|
@@ -60,7 +60,11 @@ html`<button ?disabled=${loading}>Save</button>`;
|
|
|
60
60
|
items().map((item) => html`<li>${item.name}</li>`);
|
|
61
61
|
|
|
62
62
|
// Так
|
|
63
|
-
each(
|
|
63
|
+
each(
|
|
64
|
+
items(),
|
|
65
|
+
(item) => item.id,
|
|
66
|
+
(item) => html`<li>${item.name}</li>`,
|
|
67
|
+
);
|
|
64
68
|
```
|
|
65
69
|
|
|
66
70
|
## Imports
|
|
@@ -80,3 +84,60 @@ import "./components/x-card.js";
|
|
|
80
84
|
|
|
81
85
|
Custom element name має містити дефіс. `x-*` — демо-конвенція; production може
|
|
82
86
|
мати `app-*`, `crm-*`, `ticket-*`, `admin-*`.
|
|
87
|
+
|
|
88
|
+
## `host.getAttribute()` у render — не реактивно
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
// Ні: читається один раз, не оновлюється
|
|
92
|
+
component("x-badge", ({ host }) => () => {
|
|
93
|
+
const variant = host.getAttribute("variant") ?? "default";
|
|
94
|
+
return html`<span class=${variant}>...</span>`;
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// Так: ctx.attr() — реактивний Signal
|
|
98
|
+
component("x-badge", ({ attr }) => {
|
|
99
|
+
const variant = attr("variant", "default");
|
|
100
|
+
return () => html`<span class=${() => `badge-${variant()}`}>...</span>`;
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Shadow DOM кнопка і submit форми
|
|
105
|
+
|
|
106
|
+
`<button type="submit">` всередині Shadow DOM не тригерить submit `<form>` у Light DOM.
|
|
107
|
+
Використовуйте `form.requestSubmit()` у click handler.
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
const handleClick = () => {
|
|
111
|
+
const form = host.closest("form");
|
|
112
|
+
if (form && !host.hasAttribute("disabled")) form.requestSubmit();
|
|
113
|
+
};
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## `useForm()` + Shadow DOM input
|
|
117
|
+
|
|
118
|
+
Після ретаргетингу `e.target` — це `<x-input>`, у якого немає `.name`/`.value`.
|
|
119
|
+
Додайте proxy-властивості на host:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
Object.defineProperty(host, "name", {
|
|
123
|
+
get: () => host.getAttribute("name") ?? "",
|
|
124
|
+
});
|
|
125
|
+
Object.defineProperty(host, "value", {
|
|
126
|
+
get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
|
|
127
|
+
});
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Детальніше: [`17-shadow-dom-forms.md`](./17-shadow-dom-forms.md).
|
|
131
|
+
|
|
132
|
+
## Шпаргалка
|
|
133
|
+
|
|
134
|
+
| React/інше | Mado |
|
|
135
|
+
| --------------------------- | ---------------------------- |
|
|
136
|
+
| `useState(0)` | `signal(0)` |
|
|
137
|
+
| `useEffect(() => {...})` | `effect(() => {...})` |
|
|
138
|
+
| `useMemo(() => x)` | `computed(() => x)` |
|
|
139
|
+
| `useQuery(['key'], fn)` | `resource(() => 'key', fn)` |
|
|
140
|
+
| `useRouter().push('/')` | `navigate('/')` |
|
|
141
|
+
| `class extends HTMLElement` | `component('x-name', setup)` |
|
|
142
|
+
| `host.getAttribute('x')` | `ctx.attr('x', default)` |
|
|
143
|
+
| `jsonFetcher()` з auth | `apiFetcher()` |
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Shadow DOM + форми
|
|
2
|
+
|
|
3
|
+
Використання `useForm()` з кастомними input-компонентами у Shadow DOM потребує
|
|
4
|
+
знання двох поведінок на рівні браузера:
|
|
5
|
+
|
|
6
|
+
1. **Ретаргетинг подій** — події, що спливають із Shadow DOM, мають
|
|
7
|
+
`e.target`, перенаправлений на host-елемент. `useForm().onInput` читає
|
|
8
|
+
`e.target.name` та `e.target.value`, але host-елемент `<x-input>`
|
|
9
|
+
не має цих властивостей нативно.
|
|
10
|
+
|
|
11
|
+
2. **Асоціація з формою** — `<button type="submit">` всередині Shadow Root
|
|
12
|
+
НЕ бере участі в алгоритмі form-owner для `<form>` у Light DOM. Клік по ній
|
|
13
|
+
не тригерить submit форми.
|
|
14
|
+
|
|
15
|
+
Обидва обмеження — на рівні специфікації, не баги Mado. Але фреймворк надає
|
|
16
|
+
патерни, що роблять їх безболісними.
|
|
17
|
+
|
|
18
|
+
## Патерн: Proxy-властивості на input-компонентах
|
|
19
|
+
|
|
20
|
+
Обгортаючи `<input>` у Shadow DOM компонент, експонуйте `name` та `value`
|
|
21
|
+
як DOM-властивості на host, щоб `useForm().onInput` працював після ретаргетингу:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { component, css, html } from "@madojs/mado";
|
|
25
|
+
|
|
26
|
+
component("x-input", ({ host, attr }) => {
|
|
27
|
+
const name = attr("name", "");
|
|
28
|
+
const type = attr("type", "text");
|
|
29
|
+
const value = attr("value", "");
|
|
30
|
+
|
|
31
|
+
// Proxy-властивості для сумісності з useForm().
|
|
32
|
+
// Після ретаргетингу Shadow DOM e.target з <input> → <x-input>,
|
|
33
|
+
// useForm читає e.target.name / e.target.value — ці геттери будують міст.
|
|
34
|
+
Object.defineProperty(host, "name", {
|
|
35
|
+
get: () => host.getAttribute("name") ?? "",
|
|
36
|
+
configurable: true,
|
|
37
|
+
});
|
|
38
|
+
Object.defineProperty(host, "value", {
|
|
39
|
+
get: () => host.shadowRoot?.querySelector("input")?.value ?? "",
|
|
40
|
+
set: (v: string) => {
|
|
41
|
+
const input = host.shadowRoot?.querySelector("input");
|
|
42
|
+
if (input) input.value = v;
|
|
43
|
+
},
|
|
44
|
+
configurable: true,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
return () => html`<input name=${name} type=${type} .value=${value} />`;
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Подія `input` від внутрішнього `<input>` має `composed: true` за замовчуванням,
|
|
52
|
+
тому вона спливає через межу shadow. Після ретаргетингу `e.target` —
|
|
53
|
+
це `<x-input>`, але тепер у нього є геттери `.name` та `.value` → `useForm`
|
|
54
|
+
працює.
|
|
55
|
+
|
|
56
|
+
## Патерн: Submit форми з Shadow DOM кнопок
|
|
57
|
+
|
|
58
|
+
`<button type="submit">` всередині Shadow DOM не може тригерити submit `<form>`
|
|
59
|
+
у Light DOM. Міст через `requestSubmit()`:
|
|
60
|
+
|
|
61
|
+
```ts
|
|
62
|
+
import { component, css, html } from "@madojs/mado";
|
|
63
|
+
|
|
64
|
+
component("x-button", ({ host, attr }) => {
|
|
65
|
+
const disabled = attr("disabled");
|
|
66
|
+
|
|
67
|
+
const handleClick = () => {
|
|
68
|
+
const typeAttr = host.getAttribute("type");
|
|
69
|
+
if (typeAttr === "button" || typeAttr === "reset") return;
|
|
70
|
+
const form = host.closest("form");
|
|
71
|
+
if (form && !host.hasAttribute("disabled")) form.requestSubmit();
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
return () => html`
|
|
75
|
+
<button ?disabled=${() => disabled() !== ""} @click=${handleClick}>
|
|
76
|
+
<slot></slot>
|
|
77
|
+
</button>
|
|
78
|
+
`;
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`host.closest("form")` працює, бо сам host-елемент живе в Light DOM
|
|
83
|
+
(тільки його внутрішні елементи у тіні). `requestSubmit()` тригерить валідацію
|
|
84
|
+
та подію `submit` точно так, ніби користувач клікнув нативну submit-кнопку
|
|
85
|
+
всередині форми.
|
|
86
|
+
|
|
87
|
+
## Патерн: Реактивні атрибути через ctx.attr()
|
|
88
|
+
|
|
89
|
+
З версії 0.7 `ctx.attr(name, defaultValue?)` повертає `Signal<string>`,
|
|
90
|
+
який автоматично оновлюється при зміні атрибута на host. Ніякого
|
|
91
|
+
`MutationObserver` бойлерплейту:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
component("x-badge", ({ attr }) => {
|
|
95
|
+
const variant = attr("variant", "default"); // Signal<string>
|
|
96
|
+
|
|
97
|
+
return () =>
|
|
98
|
+
html`<span class=${() => `badge badge-${variant()}`}>
|
|
99
|
+
<slot></slot>
|
|
100
|
+
</span>`;
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Батько може використовувати `?disabled=${() => !form.isValid()}` (boolean атрибут)
|
|
105
|
+
або `.variant=${"danger"}` — компонент перерендерюється реактивно в обох випадках.
|
|
106
|
+
|
|
107
|
+
## Повний приклад форми
|
|
108
|
+
|
|
109
|
+
```ts
|
|
110
|
+
import { page, html, useForm, navigate } from "@madojs/mado";
|
|
111
|
+
import "../components/x-input.js";
|
|
112
|
+
import "../components/x-button.js";
|
|
113
|
+
|
|
114
|
+
export default page({
|
|
115
|
+
title: "Вхід",
|
|
116
|
+
view: () => {
|
|
117
|
+
const form = useForm({
|
|
118
|
+
email: { required: true, type: "email" },
|
|
119
|
+
password: { required: true, minLength: 6 },
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const handleLogin = async (values) => {
|
|
123
|
+
await api("/auth/login", { method: "POST", json: values });
|
|
124
|
+
navigate("/admin");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return html`
|
|
128
|
+
<form @submit=${form.onSubmit(handleLogin)}>
|
|
129
|
+
<x-input
|
|
130
|
+
name="email"
|
|
131
|
+
type="email"
|
|
132
|
+
label="Email"
|
|
133
|
+
required
|
|
134
|
+
@input=${form.onInput}
|
|
135
|
+
@blur=${form.onBlur}
|
|
136
|
+
></x-input>
|
|
137
|
+
${() =>
|
|
138
|
+
form.errors().email
|
|
139
|
+
? html`<small class="err">${form.errors().email}</small>`
|
|
140
|
+
: null}
|
|
141
|
+
|
|
142
|
+
<x-input
|
|
143
|
+
name="password"
|
|
144
|
+
type="password"
|
|
145
|
+
label="Пароль"
|
|
146
|
+
required
|
|
147
|
+
@input=${form.onInput}
|
|
148
|
+
@blur=${form.onBlur}
|
|
149
|
+
></x-input>
|
|
150
|
+
|
|
151
|
+
<x-button type="submit" ?disabled=${() => !form.isValid()}>
|
|
152
|
+
Увійти
|
|
153
|
+
</x-button>
|
|
154
|
+
</form>
|
|
155
|
+
`;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
## Коли використовувати Light DOM
|
|
161
|
+
|
|
162
|
+
Якщо ваш input-компонент — це просто стилізована обгортка без потреби в
|
|
163
|
+
інкапсуляції, `shadow: false` уникає обох проблем (ретаргетинг та
|
|
164
|
+
form-association):
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
component(
|
|
168
|
+
"x-field",
|
|
169
|
+
({ attr }) => {
|
|
170
|
+
const label = attr("label", "");
|
|
171
|
+
return () => html`
|
|
172
|
+
<label>
|
|
173
|
+
<span>${label}</span>
|
|
174
|
+
<slot></slot>
|
|
175
|
+
</label>
|
|
176
|
+
`;
|
|
177
|
+
},
|
|
178
|
+
{ shadow: false },
|
|
179
|
+
);
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
З Light DOM нативний `<input>` — частина дерева документа, події не
|
|
183
|
+
ретаргетуються, і submit форми працює нативно. Компроміс: стилі не
|
|
184
|
+
інкапсульовані (треба скоупити самостійно).
|
|
185
|
+
|
|
186
|
+
## Підсумок
|
|
187
|
+
|
|
188
|
+
| Задача | Рішення Shadow DOM | Альтернатива Light DOM |
|
|
189
|
+
| --------------------------- | -------------------------------------- | -------------------------- |
|
|
190
|
+
| `useForm` + кастомний input | Proxy `name`/`value` на host | Нативний `<input>` у slot |
|
|
191
|
+
| Submit форми | `form.requestSubmit()` у click handler | Нативна кнопка працює |
|
|
192
|
+
| Реактивні атрибути | `ctx.attr()` → авто-сигнал | `ctx.attr()` працює скрізь |
|
|
193
|
+
| Інкапсуляція стилів | Так (автоматично) | Ручний `@scope` або BEM |
|
package/docs/uk/README.md
CHANGED
|
@@ -2,22 +2,23 @@
|
|
|
2
2
|
|
|
3
3
|
Український комплект документації.
|
|
4
4
|
|
|
5
|
-
| Розділ
|
|
6
|
-
|
|
7
|
-
| Шлях Mado
|
|
8
|
-
| Маршрутизація
|
|
9
|
-
| Структура проєкту
|
|
10
|
-
|
|
|
11
|
-
| Налаштування IDE
|
|
12
|
-
| Чому Mado
|
|
13
|
-
| Для бекендерів
|
|
14
|
-
| Типові помилки LLM
|
|
15
|
-
| LLM zero-history тест
|
|
16
|
-
| Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md)
|
|
17
|
-
| Архітектура застосунку
|
|
18
|
-
|
|
|
19
|
-
| Auth та API
|
|
20
|
-
|
|
|
21
|
-
| Тестування
|
|
22
|
-
| Обробка помилок
|
|
23
|
-
|
|
|
5
|
+
| Розділ | Файл |
|
|
6
|
+
| ----------------------- | ------------------------------------------------------------ |
|
|
7
|
+
| Шлях Mado | [00-the-mado-way.md](./00-the-mado-way.md) |
|
|
8
|
+
| Маршрутизація | [01-routing.md](./01-routing.md) |
|
|
9
|
+
| Структура проєкту | [02-project-layout.md](./02-project-layout.md) |
|
|
10
|
+
| Статичний bake & SEO | [03-static-bake.md](./03-static-bake.md) |
|
|
11
|
+
| Налаштування IDE | [04-ide-setup.md](./04-ide-setup.md) |
|
|
12
|
+
| Чому Mado | [05-why-mado.md](./05-why-mado.md) |
|
|
13
|
+
| Для бекендерів | [06-for-backenders.md](./06-for-backenders.md) |
|
|
14
|
+
| Типові помилки LLM | [07-llm-pitfalls.md](./07-llm-pitfalls.md) |
|
|
15
|
+
| LLM zero-history тест | [08-llm-zero-history-test.md](./08-llm-zero-history-test.md) |
|
|
16
|
+
| Shadow DOM vs Light DOM | [09-shadow-vs-light-dom.md](./09-shadow-vs-light-dom.md) |
|
|
17
|
+
| Архітектура застосунку | [10-app-architecture.md](./10-app-architecture.md) |
|
|
18
|
+
| Макети (layouts) | [11-layouts.md](./11-layouts.md) |
|
|
19
|
+
| Auth та API | [12-auth-and-api.md](./12-auth-and-api.md) |
|
|
20
|
+
| Розгортання | [13-deployment.md](./13-deployment.md) |
|
|
21
|
+
| Тестування | [14-testing.md](./14-testing.md) |
|
|
22
|
+
| Обробка помилок | [15-error-handling.md](./15-error-handling.md) |
|
|
23
|
+
| Рецепти bake | [16-bake-cookbook.md](./16-bake-cookbook.md) |
|
|
24
|
+
| Shadow DOM + форми | [17-shadow-dom-forms.md](./17-shadow-dom-forms.md) |
|
package/llms.txt
CHANGED
|
@@ -13,6 +13,7 @@ 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
|
+
- **Reactive attributes via `ctx.attr(name, default?)`** — returns a Signal<string> that auto-updates when the attribute changes. No MutationObserver needed.
|
|
16
17
|
|
|
17
18
|
## Critical template rules
|
|
18
19
|
|
|
@@ -64,6 +65,53 @@ import {
|
|
|
64
65
|
} from "@madojs/mado";
|
|
65
66
|
```
|
|
66
67
|
|
|
68
|
+
## Component ctx.attr() — reactive attributes
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
component("x-badge", ({ attr }) => {
|
|
72
|
+
const variant = attr("variant", "default"); // Signal<string>, auto-updates
|
|
73
|
+
const size = attr("size", "md");
|
|
74
|
+
|
|
75
|
+
return () => html`<span class=${() => `badge-${variant()} size-${size()}`}>
|
|
76
|
+
<slot></slot>
|
|
77
|
+
</span>`;
|
|
78
|
+
});
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
No MutationObserver boilerplate. The parent can bind with `?disabled=${...}` or
|
|
82
|
+
plain attribute changes and the component re-renders reactively.
|
|
83
|
+
|
|
84
|
+
## Shadow DOM + Forms
|
|
85
|
+
|
|
86
|
+
Custom inputs in Shadow DOM need two things for `useForm()` compatibility:
|
|
87
|
+
|
|
88
|
+
1. **Proxy properties** — expose `name` and `value` on the host:
|
|
89
|
+
```ts
|
|
90
|
+
Object.defineProperty(host, "name", { get: () => host.getAttribute("name") ?? "" });
|
|
91
|
+
Object.defineProperty(host, "value", { get: () => host.shadowRoot?.querySelector("input")?.value ?? "" });
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
2. **Form submit bridge** — buttons inside Shadow DOM can't submit Light DOM forms:
|
|
95
|
+
```ts
|
|
96
|
+
@click=${() => { const form = host.closest("form"); if (form) form.requestSubmit(); }}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
See `docs/en/17-shadow-dom-forms.md` for the full recipe.
|
|
100
|
+
|
|
101
|
+
## Auth fetcher for resource()
|
|
102
|
+
|
|
103
|
+
The admin starter provides `apiFetcher<T>()` in `lib/api.ts` for protected endpoints:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { resource } from "@madojs/mado";
|
|
107
|
+
import { apiFetcher } from "../lib/api.js";
|
|
108
|
+
|
|
109
|
+
const stats = resource(() => "/api/admin/stats", apiFetcher<Stats>());
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Unlike `jsonFetcher()`, `apiFetcher()` attaches the Bearer token from memory.
|
|
113
|
+
Use `jsonFetcher()` for public endpoints, `apiFetcher()` for anything behind auth.
|
|
114
|
+
|
|
67
115
|
## Canonical "Hello world"
|
|
68
116
|
|
|
69
117
|
```ts
|
|
@@ -150,6 +198,7 @@ export default page({
|
|
|
150
198
|
- docs/en/14-testing.md — testing strategy and commands
|
|
151
199
|
- docs/en/15-error-handling.md — route/data/action error boundaries
|
|
152
200
|
- docs/en/16-bake-cookbook.md — static bake recipes and failure modes
|
|
201
|
+
- docs/en/17-shadow-dom-forms.md — Shadow DOM + useForm() patterns (proxy properties, form submit bridge)
|
|
153
202
|
- examples/basic/ — minimal API tour
|
|
154
203
|
- examples/tickets/ — LLM zero-history CRUD validation
|
|
155
204
|
- examples/showcase/ — flagship CRM pressure app (auth, nested routes, forms, mutations)
|
|
@@ -170,7 +219,7 @@ export default page({
|
|
|
170
219
|
|
|
171
220
|
## Version
|
|
172
221
|
|
|
173
|
-
`0.
|
|
222
|
+
`0.7.0` — pre-1.0 product-surface release. API may still change before 1.0.
|
|
174
223
|
Semver is not guaranteed on minor versions before 1.0.
|
|
175
224
|
|
|
176
225
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@madojs/mado",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Mado — a small native-web SPA framework with Web Components, signals, tagged-template html, router, resources, and forms. TypeScript-only build, zero runtime dependencies.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -80,4 +80,4 @@
|
|
|
80
80
|
"engines": {
|
|
81
81
|
"node": ">=20"
|
|
82
82
|
}
|
|
83
|
-
}
|
|
83
|
+
}
|
package/scripts/bake.mjs
CHANGED
|
@@ -41,19 +41,19 @@ const { flags } = parseFlags(process.argv.slice(2));
|
|
|
41
41
|
const cfg = loadConfig({
|
|
42
42
|
overrides: {
|
|
43
43
|
bake: {
|
|
44
|
-
entry:
|
|
44
|
+
entry: typeof flags.entry === "string" ? flags.entry : undefined,
|
|
45
45
|
template: typeof flags.template === "string" ? flags.template : undefined,
|
|
46
|
-
baseUrl:
|
|
47
|
-
outDir:
|
|
46
|
+
baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
|
|
47
|
+
outDir: typeof flags.out === "string" ? flags.out : undefined,
|
|
48
48
|
},
|
|
49
49
|
},
|
|
50
50
|
});
|
|
51
51
|
|
|
52
52
|
// Env vars are legacy escape hatches (kept so old CI keeps working).
|
|
53
|
-
const ENTRY
|
|
54
|
-
const TEMPLATE
|
|
55
|
-
const BASE_URL
|
|
56
|
-
const OUT_DIR
|
|
53
|
+
const ENTRY = process.env.ENTRY ?? resolveProjectPath(cfg, cfg.bake.entry);
|
|
54
|
+
const TEMPLATE = process.env.TEMPLATE ?? resolveProjectPath(cfg, cfg.bake.template);
|
|
55
|
+
const BASE_URL = process.env.BASE_URL ?? cfg.bake.baseUrl;
|
|
56
|
+
const OUT_DIR = process.env.OUT_DIR
|
|
57
57
|
?? resolveProjectPath(cfg, cfg.bake.outDir ?? join(cfg.build.out, "baked"));
|
|
58
58
|
|
|
59
59
|
/** Write message to stderr and exit. Sync write keeps CI/execFile output reliable. */
|
|
@@ -85,14 +85,26 @@ let parseHTML;
|
|
|
85
85
|
try {
|
|
86
86
|
({ parseHTML } = await import("linkedom"));
|
|
87
87
|
} catch {
|
|
88
|
-
fatal(
|
|
88
|
+
fatal(
|
|
89
|
+
"[bake] package 'linkedom' is required.",
|
|
90
|
+
"[bake] Install it as a dev dependency in this project:",
|
|
91
|
+
"[bake] npm i -D linkedom esbuild",
|
|
92
|
+
"[bake] (esbuild is also required, see next check).",
|
|
93
|
+
"[bake] These are not bundled into @madojs/mado on purpose: bake is an",
|
|
94
|
+
"[bake] optional build step and we don't want to add transitive deps to",
|
|
95
|
+
"[bake] every Mado install.",
|
|
96
|
+
);
|
|
89
97
|
}
|
|
90
98
|
|
|
91
99
|
let esbuild;
|
|
92
100
|
try {
|
|
93
101
|
esbuild = await import("esbuild");
|
|
94
102
|
} catch {
|
|
95
|
-
fatal(
|
|
103
|
+
fatal(
|
|
104
|
+
"[bake] package 'esbuild' is required.",
|
|
105
|
+
"[bake] Install it as a dev dependency in this project:",
|
|
106
|
+
"[bake] npm i -D esbuild linkedom",
|
|
107
|
+
);
|
|
96
108
|
}
|
|
97
109
|
|
|
98
110
|
// ---------- DOM polyfills for Node ----------
|
|
@@ -107,21 +119,21 @@ const { window: linkedomWindow } = parseHTML(baseHtml);
|
|
|
107
119
|
globalThis.window = linkedomWindow;
|
|
108
120
|
globalThis.document = linkedomWindow.document;
|
|
109
121
|
globalThis.location = new URL("http://localhost/");
|
|
110
|
-
globalThis.history = { pushState: () => {}, replaceState: () => {} };
|
|
122
|
+
globalThis.history = { pushState: () => { }, replaceState: () => { } };
|
|
111
123
|
globalThis.customElements = {
|
|
112
|
-
define: () => {},
|
|
124
|
+
define: () => { },
|
|
113
125
|
get: () => undefined,
|
|
114
126
|
whenDefined: () => Promise.resolve(),
|
|
115
127
|
};
|
|
116
|
-
globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class {};
|
|
128
|
+
globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class { };
|
|
117
129
|
globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? class {
|
|
118
130
|
cssRules = [];
|
|
119
|
-
replaceSync() {}
|
|
131
|
+
replaceSync() { }
|
|
120
132
|
};
|
|
121
133
|
globalThis.matchMedia = () => ({
|
|
122
134
|
matches: false,
|
|
123
|
-
addEventListener: () => {},
|
|
124
|
-
removeEventListener: () => {},
|
|
135
|
+
addEventListener: () => { },
|
|
136
|
+
removeEventListener: () => { },
|
|
125
137
|
});
|
|
126
138
|
if (!globalThis.queueMicrotask) {
|
|
127
139
|
globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
|
|
@@ -155,7 +167,7 @@ await esbuild.build({
|
|
|
155
167
|
|
|
156
168
|
const routesUrl = pathToFileURL(tmpFile).href;
|
|
157
169
|
const routesModule = await import(routesUrl);
|
|
158
|
-
await rm(tmpFile).catch(() => {});
|
|
170
|
+
await rm(tmpFile).catch(() => { });
|
|
159
171
|
const routeApi = routesModule.default;
|
|
160
172
|
|
|
161
173
|
if (!routeApi) {
|
|
@@ -183,13 +195,19 @@ const TEMPLATE_HTML = baseHtml;
|
|
|
183
195
|
const sitemapEntries = [];
|
|
184
196
|
let total = 0;
|
|
185
197
|
let bakedErrors = 0;
|
|
198
|
+
let bakeablePages = 0;
|
|
199
|
+
const skippedNoBake = [];
|
|
186
200
|
|
|
187
201
|
for (const [pattern, entry] of Object.entries(manifest)) {
|
|
188
202
|
if (pattern === "*") continue;
|
|
189
203
|
|
|
190
204
|
const pg = await resolvePage(entry);
|
|
191
205
|
if (!pg) continue;
|
|
192
|
-
if (!pg.bake)
|
|
206
|
+
if (!pg.bake) {
|
|
207
|
+
skippedNoBake.push(pattern);
|
|
208
|
+
continue;
|
|
209
|
+
}
|
|
210
|
+
bakeablePages++;
|
|
193
211
|
|
|
194
212
|
console.log(`[bake] ${pattern}`);
|
|
195
213
|
|
|
@@ -214,6 +232,12 @@ for (const [pattern, entry] of Object.entries(manifest)) {
|
|
|
214
232
|
}
|
|
215
233
|
const headMeta = pg.head ? pg.head(params, data) : {};
|
|
216
234
|
|
|
235
|
+
// Ensure <title> is always set in baked HTML. page.title is the primary
|
|
236
|
+
// source (string or function of params); head().title overrides it.
|
|
237
|
+
if (!headMeta.title && pg.title) {
|
|
238
|
+
headMeta.title = typeof pg.title === "function" ? pg.title(params) : pg.title;
|
|
239
|
+
}
|
|
240
|
+
|
|
217
241
|
const tpl = pg.view({
|
|
218
242
|
params,
|
|
219
243
|
data,
|
|
@@ -255,11 +279,11 @@ for (const [pattern, entry] of Object.entries(manifest)) {
|
|
|
255
279
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
256
280
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
257
281
|
${sitemapEntries
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
282
|
+
.map(
|
|
283
|
+
(e) =>
|
|
284
|
+
` <url><loc>${escapeXml(e.loc)}</loc><changefreq>${e.changefreq}</changefreq></url>`,
|
|
285
|
+
)
|
|
286
|
+
.join("\n")}
|
|
263
287
|
</urlset>
|
|
264
288
|
`;
|
|
265
289
|
await writeFile(join(OUT_DIR, "sitemap.xml"), sitemap);
|
|
@@ -268,6 +292,36 @@ console.log(`[bake] done: ${total} pages + sitemap.xml → ${OUT_DIR}`);
|
|
|
268
292
|
if (bakedErrors > 0) {
|
|
269
293
|
fatal(`[bake] ${bakedErrors} route(s) failed; see errors above.`);
|
|
270
294
|
}
|
|
295
|
+
// Loud diagnostic when the manifest exists but no page declares `bake`.
|
|
296
|
+
// Previously bake silently produced 0 pages + an empty sitemap and exited
|
|
297
|
+
// 0, which made `mado release` look successful while shipping no static
|
|
298
|
+
// HTML for crawlers. Fail loudly so the user notices.
|
|
299
|
+
if (bakeablePages === 0) {
|
|
300
|
+
error("");
|
|
301
|
+
error(
|
|
302
|
+
`[bake] WARNING: no page in ${ENTRY} declares \`bake: { paths, data }\`.`,
|
|
303
|
+
);
|
|
304
|
+
error(
|
|
305
|
+
`[bake] ${skippedNoBake.length} route(s) skipped: ${skippedNoBake
|
|
306
|
+
.slice(0, 6)
|
|
307
|
+
.join(", ")}${skippedNoBake.length > 6 ? ", …" : ""}`,
|
|
308
|
+
);
|
|
309
|
+
error("[bake] Add `bake` to at least one page (e.g. your landing route):");
|
|
310
|
+
error("[bake] export default page({");
|
|
311
|
+
error("[bake] view: …,");
|
|
312
|
+
error("[bake] bake: { paths: () => [{}], data: () => ({}) },");
|
|
313
|
+
error("[bake] });");
|
|
314
|
+
error(
|
|
315
|
+
"[bake] Without bake the build ships only the SPA shell — search engines",
|
|
316
|
+
);
|
|
317
|
+
error("[bake] and link previews see an empty <body>.");
|
|
318
|
+
// Exit non-zero so `mado release` halts and the user is forced to address
|
|
319
|
+
// it. If you intentionally have an SPA-only deploy, drop `mado bake` from
|
|
320
|
+
// the release pipeline (or set MADO_BAKE_ALLOW_EMPTY=1).
|
|
321
|
+
if (process.env.MADO_BAKE_ALLOW_EMPTY !== "1") {
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
271
325
|
|
|
272
326
|
// ---------- Helpers ----------
|
|
273
327
|
|
package/scripts/bundle.mjs
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
// its biggest example.
|
|
26
26
|
|
|
27
27
|
import { build } from "esbuild";
|
|
28
|
-
import { readFile, writeFile, mkdir, cp, stat, readdir } from "node:fs/promises";
|
|
28
|
+
import { readFile, writeFile, mkdir, cp, stat, readdir, rm } from "node:fs/promises";
|
|
29
29
|
import { createHash } from "node:crypto";
|
|
30
30
|
import { gzipSync, brotliCompressSync, constants as zlibConst } from "node:zlib";
|
|
31
31
|
import { join, basename, resolve, dirname } from "node:path";
|
|
@@ -74,6 +74,29 @@ if (!existsSync(HTML)) {
|
|
|
74
74
|
process.exit(1);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
+
// Clean stale assets from previous bundles.
|
|
78
|
+
//
|
|
79
|
+
// Without this step, hashed chunks from prior runs (main-<oldhash>.js,
|
|
80
|
+
// chunk-<oldhash>.js) accumulate in ASSETS_DIR. We later list ASSETS_DIR
|
|
81
|
+
// (via readdir below) and emit <link rel="modulepreload"> for every
|
|
82
|
+
// .js file we find — so stale chunks would be preloaded as if they were
|
|
83
|
+
// still part of the app, polluting the production HTML and shipping dead
|
|
84
|
+
// code over the wire. SRI is also only computed for the fresh entry, so
|
|
85
|
+
// stale preloads would lack integrity checks.
|
|
86
|
+
//
|
|
87
|
+
// In app-mode the entire <out>/assets/ folder is owned by the bundler,
|
|
88
|
+
// so wiping it is safe. In repo-mode the historical layout drops assets
|
|
89
|
+
// directly into <out>/ alongside non-bundle artifacts, so we only remove
|
|
90
|
+
// the recognisable hashed files there.
|
|
91
|
+
if (ASSETS_REL) {
|
|
92
|
+
await rm(ASSETS_DIR, { recursive: true, force: true });
|
|
93
|
+
} else if (existsSync(ASSETS_DIR)) {
|
|
94
|
+
for (const f of await readdir(ASSETS_DIR)) {
|
|
95
|
+
if (/^(main|chunk|asset)-[A-Z0-9]+\.(js|css)(\.map|\.gz|\.br)?$/i.test(f)) {
|
|
96
|
+
await rm(join(ASSETS_DIR, f), { force: true });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
77
100
|
await mkdir(ASSETS_DIR, { recursive: true });
|
|
78
101
|
|
|
79
102
|
console.log(`[bundle] entry: ${ENTRY}`);
|