@madojs/mado 0.6.1 → 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 +98 -3
- package/dist/src/component.d.ts +17 -4
- package/dist/src/component.js +26 -4
- package/dist/src/component.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 +25 -19
- package/scripts/cli.mjs +22 -33
- 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
|
@@ -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. */
|
|
@@ -119,21 +119,21 @@ const { window: linkedomWindow } = parseHTML(baseHtml);
|
|
|
119
119
|
globalThis.window = linkedomWindow;
|
|
120
120
|
globalThis.document = linkedomWindow.document;
|
|
121
121
|
globalThis.location = new URL("http://localhost/");
|
|
122
|
-
globalThis.history = { pushState: () => {}, replaceState: () => {} };
|
|
122
|
+
globalThis.history = { pushState: () => { }, replaceState: () => { } };
|
|
123
123
|
globalThis.customElements = {
|
|
124
|
-
define: () => {},
|
|
124
|
+
define: () => { },
|
|
125
125
|
get: () => undefined,
|
|
126
126
|
whenDefined: () => Promise.resolve(),
|
|
127
127
|
};
|
|
128
|
-
globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class {};
|
|
128
|
+
globalThis.HTMLElement = linkedomWindow.HTMLElement ?? class { };
|
|
129
129
|
globalThis.CSSStyleSheet = globalThis.CSSStyleSheet ?? class {
|
|
130
130
|
cssRules = [];
|
|
131
|
-
replaceSync() {}
|
|
131
|
+
replaceSync() { }
|
|
132
132
|
};
|
|
133
133
|
globalThis.matchMedia = () => ({
|
|
134
134
|
matches: false,
|
|
135
|
-
addEventListener: () => {},
|
|
136
|
-
removeEventListener: () => {},
|
|
135
|
+
addEventListener: () => { },
|
|
136
|
+
removeEventListener: () => { },
|
|
137
137
|
});
|
|
138
138
|
if (!globalThis.queueMicrotask) {
|
|
139
139
|
globalThis.queueMicrotask = (fn) => Promise.resolve().then(fn);
|
|
@@ -167,7 +167,7 @@ await esbuild.build({
|
|
|
167
167
|
|
|
168
168
|
const routesUrl = pathToFileURL(tmpFile).href;
|
|
169
169
|
const routesModule = await import(routesUrl);
|
|
170
|
-
await rm(tmpFile).catch(() => {});
|
|
170
|
+
await rm(tmpFile).catch(() => { });
|
|
171
171
|
const routeApi = routesModule.default;
|
|
172
172
|
|
|
173
173
|
if (!routeApi) {
|
|
@@ -232,6 +232,12 @@ for (const [pattern, entry] of Object.entries(manifest)) {
|
|
|
232
232
|
}
|
|
233
233
|
const headMeta = pg.head ? pg.head(params, data) : {};
|
|
234
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
|
+
|
|
235
241
|
const tpl = pg.view({
|
|
236
242
|
params,
|
|
237
243
|
data,
|
|
@@ -273,11 +279,11 @@ for (const [pattern, entry] of Object.entries(manifest)) {
|
|
|
273
279
|
const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
|
|
274
280
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
275
281
|
${sitemapEntries
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
282
|
+
.map(
|
|
283
|
+
(e) =>
|
|
284
|
+
` <url><loc>${escapeXml(e.loc)}</loc><changefreq>${e.changefreq}</changefreq></url>`,
|
|
285
|
+
)
|
|
286
|
+
.join("\n")}
|
|
281
287
|
</urlset>
|
|
282
288
|
`;
|
|
283
289
|
await writeFile(join(OUT_DIR, "sitemap.xml"), sitemap);
|
package/scripts/cli.mjs
CHANGED
|
@@ -92,11 +92,12 @@ async function runServe(rawArgs) {
|
|
|
92
92
|
// example name; everything else (including `--host`, `--port`, etc.) is
|
|
93
93
|
// forwarded verbatim to server/serve.mjs.
|
|
94
94
|
const { example, forwarded } = splitDevArgs(rawArgs);
|
|
95
|
-
if (!example && PROJECT_ROOT !== PACKAGE_ROOT) {
|
|
96
|
-
await serveStaticProject(PROJECT_ROOT);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
95
|
if (example) assertExample(example, { serveable: true });
|
|
96
|
+
|
|
97
|
+
// In app-mode (generated project, no example argument) we also go through
|
|
98
|
+
// server/serve.mjs to get config support (--host, --port, mado.config.json
|
|
99
|
+
// dev.proxy, HMR, etc.) — previously this fell back to serveStaticProject()
|
|
100
|
+
// which only read PORT from env and had no proxy/config/HMR.
|
|
100
101
|
await run(
|
|
101
102
|
process.execPath,
|
|
102
103
|
[join(PACKAGE_ROOT, "server/serve.mjs"), example, ...forwarded].filter(
|
|
@@ -235,7 +236,8 @@ async function runRelease(rawArgs) {
|
|
|
235
236
|
// have to remember the order, and so the deploy artifact (out/) is always
|
|
236
237
|
// assembled the same way.
|
|
237
238
|
//
|
|
238
|
-
// mado release
|
|
239
|
+
// mado release [--no-clean]
|
|
240
|
+
// → rm -rf out/ (unless --no-clean)
|
|
239
241
|
// → mado typecheck
|
|
240
242
|
// → mado build (tsc → dist/)
|
|
241
243
|
// → mado bundle (esbuild → out/assets/, also copies index.html)
|
|
@@ -243,6 +245,7 @@ async function runRelease(rawArgs) {
|
|
|
243
245
|
// → copy public/* → out/
|
|
244
246
|
//
|
|
245
247
|
// Flags are forwarded to bake/bundle.
|
|
248
|
+
const { flags: releaseFlags } = parseFlags(rawArgs);
|
|
246
249
|
const cfg = loadConfig({ projectRoot: PROJECT_ROOT });
|
|
247
250
|
const outDir = resolve(cfg.projectRoot, cfg.build.out ?? "out");
|
|
248
251
|
const publicDir = resolve(cfg.projectRoot, cfg.build.publicDir ?? "public");
|
|
@@ -251,6 +254,18 @@ async function runRelease(rawArgs) {
|
|
|
251
254
|
console.log(`[release] artifact: ${outDir}`);
|
|
252
255
|
console.log("");
|
|
253
256
|
|
|
257
|
+
// Deterministic builds: remove the entire output directory so stale assets,
|
|
258
|
+
// removed bake routes, and deleted public files don't linger in the deploy
|
|
259
|
+
// artifact. Use --no-clean to opt out (e.g. incremental CI workflows).
|
|
260
|
+
if (!releaseFlags["no-clean"]) {
|
|
261
|
+
if (existsSync(outDir)) {
|
|
262
|
+
await rm(outDir, { recursive: true, force: true });
|
|
263
|
+
console.log(`[release] cleaned ${outDir}`);
|
|
264
|
+
}
|
|
265
|
+
} else {
|
|
266
|
+
console.log("[release] --no-clean: keeping existing out/");
|
|
267
|
+
}
|
|
268
|
+
|
|
254
269
|
console.log("[release] step 1/5 typecheck");
|
|
255
270
|
await runNodeBin("typescript/bin/tsc", ["--noEmit"]);
|
|
256
271
|
|
|
@@ -527,31 +542,5 @@ function contentType(file) {
|
|
|
527
542
|
}[ext] ?? "application/octet-stream";
|
|
528
543
|
}
|
|
529
544
|
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
const server = http.createServer((req, res) => {
|
|
533
|
-
const url = new URL(req.url || "/", `http://localhost:${port}`);
|
|
534
|
-
const pathname = decodeURIComponent(url.pathname);
|
|
535
|
-
const normalized = pathname.replace(/^\/+/, "");
|
|
536
|
-
let file = resolve(rootDir, normalized);
|
|
537
|
-
if (pathname === "/" || !normalized.includes(".")) file = join(rootDir, "index.html");
|
|
538
|
-
if (!file.startsWith(rootDir) || !existsSync(file) || statSync(file).isDirectory()) {
|
|
539
|
-
file = join(rootDir, "index.html");
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
if (!existsSync(file)) {
|
|
543
|
-
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
544
|
-
res.end("Not found");
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
res.writeHead(200, { "content-type": contentType(file) });
|
|
549
|
-
res.end(readFileSync(file));
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
await new Promise((resolveListen) => {
|
|
553
|
-
server.listen(port, resolveListen);
|
|
554
|
-
});
|
|
555
|
-
console.log(`[mado] serving ${rootDir}`);
|
|
556
|
-
console.log(`[mado] http://localhost:${port}`);
|
|
557
|
-
}
|
|
545
|
+
// serveStaticProject removed in v0.7 — mado serve now always goes through
|
|
546
|
+
// server/serve.mjs to get --host, --port, dev.proxy, and HMR support.
|
|
@@ -1,25 +1,45 @@
|
|
|
1
1
|
// <x-button variant="primary|ghost|danger" ?disabled>
|
|
2
2
|
//
|
|
3
3
|
// Wraps a native <button> so it can be slotted with text/icon and styled
|
|
4
|
-
// consistently across the app.
|
|
5
|
-
//
|
|
4
|
+
// consistently across the app.
|
|
5
|
+
//
|
|
6
|
+
// Handles two Shadow DOM gotchas out of the box:
|
|
7
|
+
// 1. Reactive attributes via ctx.attr() — external ?disabled changes
|
|
8
|
+
// re-render the inner button automatically.
|
|
9
|
+
// 2. Form submit — a <button type="submit"> inside Shadow DOM cannot
|
|
10
|
+
// trigger <form> submit in Light DOM (spec limitation). We call
|
|
11
|
+
// form.requestSubmit() from a click handler to bridge this gap.
|
|
6
12
|
|
|
7
13
|
import { component, css, html } from "@madojs/mado";
|
|
8
14
|
|
|
9
15
|
component(
|
|
10
16
|
"x-button",
|
|
11
|
-
({ host }) =>
|
|
12
|
-
const variant =
|
|
13
|
-
const disabled =
|
|
14
|
-
|
|
15
|
-
|
|
17
|
+
({ host, attr }) => {
|
|
18
|
+
const variant = attr("variant", "primary");
|
|
19
|
+
const disabled = attr("disabled");
|
|
20
|
+
|
|
21
|
+
const handleClick = () => {
|
|
22
|
+
const typeAttr = host.getAttribute("type");
|
|
23
|
+
if (typeAttr === "button" || typeAttr === "reset") return;
|
|
24
|
+
const form = host.closest("form");
|
|
25
|
+
if (form && !host.hasAttribute("disabled")) form.requestSubmit();
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return () => html`
|
|
29
|
+
<button
|
|
30
|
+
data-variant=${variant()}
|
|
31
|
+
?disabled=${() => disabled() !== ""}
|
|
32
|
+
@click=${handleClick}
|
|
33
|
+
>
|
|
16
34
|
<slot></slot>
|
|
17
35
|
</button>
|
|
18
36
|
`;
|
|
19
37
|
},
|
|
20
38
|
{
|
|
21
39
|
styles: css`
|
|
22
|
-
:host {
|
|
40
|
+
:host {
|
|
41
|
+
display: inline-flex;
|
|
42
|
+
}
|
|
23
43
|
button {
|
|
24
44
|
display: inline-flex;
|
|
25
45
|
align-items: center;
|
|
@@ -31,11 +51,18 @@ component(
|
|
|
31
51
|
cursor: pointer;
|
|
32
52
|
background: var(--accent);
|
|
33
53
|
color: var(--accent-fg);
|
|
34
|
-
transition: filter .12s ease;
|
|
54
|
+
transition: filter 0.12s ease;
|
|
55
|
+
}
|
|
56
|
+
button:hover:not(:disabled) {
|
|
57
|
+
filter: brightness(1.07);
|
|
58
|
+
}
|
|
59
|
+
button:active:not(:disabled) {
|
|
60
|
+
filter: brightness(0.95);
|
|
61
|
+
}
|
|
62
|
+
button:disabled {
|
|
63
|
+
opacity: 0.55;
|
|
64
|
+
cursor: not-allowed;
|
|
35
65
|
}
|
|
36
|
-
button:hover:not(:disabled) { filter: brightness(1.07); }
|
|
37
|
-
button:active:not(:disabled) { filter: brightness(.95); }
|
|
38
|
-
button:disabled { opacity: .55; cursor: not-allowed; }
|
|
39
66
|
|
|
40
67
|
button[data-variant="ghost"] {
|
|
41
68
|
background: transparent;
|
|
@@ -52,4 +79,4 @@ component(
|
|
|
52
79
|
}
|
|
53
80
|
`,
|
|
54
81
|
},
|
|
55
|
-
);
|
|
82
|
+
);
|