@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.
Files changed (44) hide show
  1. package/AGENTS.md +82 -30
  2. package/CHANGELOG.md +208 -1
  3. package/dist/src/component.d.ts +17 -4
  4. package/dist/src/component.js +26 -4
  5. package/dist/src/component.js.map +1 -1
  6. package/dist/src/resource.js +11 -0
  7. package/dist/src/resource.js.map +1 -1
  8. package/dist/src/router/manifest.js +29 -2
  9. package/dist/src/router/manifest.js.map +1 -1
  10. package/docs/en/07-llm-pitfalls.md +197 -60
  11. package/docs/en/08-llm-zero-history-test.md +1 -1
  12. package/docs/en/17-shadow-dom-forms.md +192 -0
  13. package/docs/en/README.md +20 -19
  14. package/docs/fr/07-llm-pitfalls.md +196 -60
  15. package/docs/fr/17-shadow-dom-forms.md +196 -0
  16. package/docs/fr/README.md +20 -19
  17. package/docs/ru/07-llm-pitfalls.md +198 -61
  18. package/docs/ru/08-llm-zero-history-test.md +39 -38
  19. package/docs/ru/09-shadow-vs-light-dom.md +97 -81
  20. package/docs/ru/17-shadow-dom-forms.md +193 -0
  21. package/docs/ru/README.md +20 -19
  22. package/docs/uk/07-llm-pitfalls.md +64 -3
  23. package/docs/uk/17-shadow-dom-forms.md +193 -0
  24. package/docs/uk/README.md +20 -19
  25. package/llms.txt +50 -1
  26. package/package.json +2 -2
  27. package/scripts/bake.mjs +76 -22
  28. package/scripts/bundle.mjs +24 -1
  29. package/scripts/cli.mjs +98 -45
  30. package/scripts/preview.mjs +104 -10
  31. package/server/serve.mjs +80 -7
  32. package/starters/admin/index.html +10 -3
  33. package/starters/admin/package.json +3 -1
  34. package/starters/admin/src/components/x-button.ts +40 -13
  35. package/starters/admin/src/components/x-input.ts +50 -19
  36. package/starters/admin/src/lib/api.ts +55 -4
  37. package/starters/admin/src/pages/admin/order-detail.ts +4 -2
  38. package/starters/admin/src/pages/home.ts +10 -1
  39. package/starters/crud/index.html +12 -4
  40. package/starters/crud/package.json +3 -1
  41. package/starters/crud/src/pages/home.ts +16 -0
  42. package/starters/minimal/index.html +12 -4
  43. package/starters/minimal/package.json +2 -0
  44. 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(items(), (item) => item.id, (item) => html`<li>${item.name}</li>`);
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 | [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
- | Static 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
- | Deployment | [13-deployment.md](./13-deployment.md) |
21
- | Тестування | [14-testing.md](./14-testing.md) |
22
- | Обробка помилок | [15-error-handling.md](./15-error-handling.md) |
23
- | Bake cookbook | [16-bake-cookbook.md](./16-bake-cookbook.md) |
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.6.0` — pre-1.0 product-surface release. API may still change before 1.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.6.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: typeof flags.entry === "string" ? flags.entry : undefined,
44
+ entry: typeof flags.entry === "string" ? flags.entry : undefined,
45
45
  template: typeof flags.template === "string" ? flags.template : undefined,
46
- baseUrl: typeof flags["base-url"] === "string" ? flags["base-url"] : undefined,
47
- outDir: typeof flags.out === "string" ? flags.out : undefined,
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 = 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
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("[bake] package 'linkedom' is required: npm i -D linkedom");
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("[bake] package 'esbuild' is required: npm i -D esbuild");
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) continue;
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
- .map(
259
- (e) =>
260
- ` <url><loc>${escapeXml(e.loc)}</loc><changefreq>${e.changefreq}</changefreq></url>`,
261
- )
262
- .join("\n")}
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
 
@@ -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}`);