@madojs/mado 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/AGENTS.md +102 -25
  2. package/CHANGELOG.md +185 -1
  3. package/README.md +137 -172
  4. package/dist/src/component.d.ts +3 -2
  5. package/dist/src/component.js +98 -6
  6. package/dist/src/component.js.map +1 -1
  7. package/dist/src/css.js +34 -5
  8. package/dist/src/css.js.map +1 -1
  9. package/dist/src/head.d.ts +6 -6
  10. package/dist/src/head.js +6 -6
  11. package/dist/src/html/bindings.js +3 -3
  12. package/dist/src/html/bindings.js.map +1 -1
  13. package/dist/src/html/parser.d.ts +1 -1
  14. package/dist/src/html/parser.js +1 -1
  15. package/dist/src/html/template.js +24 -10
  16. package/dist/src/html/template.js.map +1 -1
  17. package/dist/src/index.d.ts +8 -4
  18. package/dist/src/index.js +13 -3
  19. package/dist/src/index.js.map +1 -1
  20. package/dist/src/page.d.ts +62 -28
  21. package/dist/src/page.js +5 -0
  22. package/dist/src/page.js.map +1 -1
  23. package/dist/src/resource.js +2 -1
  24. package/dist/src/resource.js.map +1 -1
  25. package/dist/src/router/base.d.ts +65 -0
  26. package/dist/src/router/base.js +168 -0
  27. package/dist/src/router/base.js.map +1 -0
  28. package/dist/src/router/manifest.js +75 -31
  29. package/dist/src/router/manifest.js.map +1 -1
  30. package/dist/src/router/match.d.ts +16 -2
  31. package/dist/src/router/match.js +41 -1
  32. package/dist/src/router/match.js.map +1 -1
  33. package/dist/src/router/navigation.js +58 -9
  34. package/dist/src/router/navigation.js.map +1 -1
  35. package/dist/src/static-runtime.d.ts +81 -0
  36. package/dist/src/static-runtime.js +209 -0
  37. package/dist/src/static-runtime.js.map +1 -0
  38. package/dist/src/vite/index.d.ts +31 -3
  39. package/dist/src/vite/index.js +49 -18
  40. package/dist/src/vite/index.js.map +1 -1
  41. package/docs/README.md +5 -8
  42. package/docs/architecture/adr/0001-browser-static-snapshots.md +132 -0
  43. package/docs/en/00-the-mado-way.md +1 -1
  44. package/docs/en/01-quickstart.md +183 -0
  45. package/docs/en/10-pages-and-components.md +271 -0
  46. package/docs/en/11-templates-and-signals.md +211 -0
  47. package/docs/en/12-routing.md +229 -0
  48. package/docs/en/13-data.md +225 -0
  49. package/docs/en/14-forms.md +244 -0
  50. package/docs/en/15-static-snapshots.md +181 -0
  51. package/docs/en/{10-app-architecture.md → 16-app-architecture.md} +53 -2
  52. package/docs/en/{13-deployment.md → 20-deployment.md} +59 -10
  53. package/docs/en/{14-testing.md → 22-testing.md} +6 -5
  54. package/docs/en/23-cookbook.md +292 -0
  55. package/docs/en/{18-api-freeze-map.md → 30-api-freeze-map.md} +12 -3
  56. package/docs/en/{20-v1-stability.md → 32-v1-stability.md} +3 -3
  57. package/docs/en/40-llm-guide.md +776 -0
  58. package/docs/en/{06-for-backenders.md → 41-for-backenders.md} +15 -16
  59. package/docs/en/{05-why-mado.md → 42-why-mado.md} +1 -1
  60. package/docs/en/README.md +97 -27
  61. package/docs/recipes/nginx/Containerfile +26 -0
  62. package/docs/recipes/nginx/nginx.conf +42 -0
  63. package/llms.txt +246 -211
  64. package/package.json +17 -10
  65. package/scripts/bake.mjs +4 -568
  66. package/scripts/cli/generate.mjs +55 -5
  67. package/scripts/cli/help.mjs +11 -5
  68. package/scripts/cli/index.mjs +20 -4
  69. package/scripts/cli/init.mjs +7 -2
  70. package/scripts/cli/release.mjs +20 -68
  71. package/scripts/docs-lint.mjs +170 -0
  72. package/scripts/package-smoke.mjs +67 -9
  73. package/scripts/preview.mjs +95 -6
  74. package/scripts/size-budget.mjs +5 -1
  75. package/scripts/static/browser.mjs +654 -0
  76. package/scripts/static/discover.mjs +281 -0
  77. package/scripts/static/output.mjs +141 -0
  78. package/scripts/static/serialize.mjs +212 -0
  79. package/scripts/static/server.mjs +167 -0
  80. package/scripts/static.mjs +191 -0
  81. package/starters/default/README.md +57 -55
  82. package/starters/default/package.json +3 -9
  83. package/starters/default/src/app.routes.ts +20 -33
  84. package/starters/default/src/components/feature-card.component.ts +40 -0
  85. package/starters/default/src/components/live-counter.component.ts +57 -0
  86. package/starters/default/src/content/guides.ts +55 -0
  87. package/starters/default/src/main.ts +9 -13
  88. package/starters/default/src/pages/app.page.ts +41 -0
  89. package/starters/default/src/pages/guide.page.ts +40 -0
  90. package/starters/default/src/pages/home.page.ts +49 -0
  91. package/starters/default/src/pages/not-found.page.ts +18 -0
  92. package/starters/default/src/styles/document.css +39 -0
  93. package/starters/default/src/styles/reset.css +37 -0
  94. package/starters/default/src/styles/tokens.css +30 -0
  95. package/starters/default/src/styles.d.ts +3 -1
  96. package/starters/default/src/vite-env.d.ts +1 -1
  97. package/starters/default/vite.config.ts +14 -1
  98. package/starters/modular/README.md +142 -0
  99. package/starters/modular/index.html +13 -0
  100. package/starters/modular/package.json +30 -0
  101. package/starters/modular/src/app.routes.ts +39 -0
  102. package/starters/{default → modular}/src/layouts/app-shell.layout.ts +8 -5
  103. package/starters/{default → modular}/src/layouts/auth-shell.layout.ts +3 -3
  104. package/starters/modular/src/main.ts +16 -0
  105. package/starters/{default → modular}/src/modules/billing/pages/invoices-list.page.ts +3 -3
  106. package/starters/{default → modular}/src/modules/home/home.page.ts +5 -7
  107. package/starters/modular/src/modules/home/not-found.page.ts +14 -0
  108. package/starters/modular/src/styles.d.ts +1 -0
  109. package/starters/modular/src/vite-env.d.ts +1 -0
  110. package/starters/modular/tsconfig.json +24 -0
  111. package/starters/modular/vite.config.ts +131 -0
  112. package/TODO.md +0 -79
  113. package/docs/en/01-routing.md +0 -152
  114. package/docs/en/02-project-layout.md +0 -124
  115. package/docs/en/03-static-bake.md +0 -249
  116. package/docs/en/04-ide-setup.md +0 -162
  117. package/docs/en/07-llm-pitfalls.md +0 -724
  118. package/docs/en/08-llm-zero-history-test.md +0 -56
  119. package/docs/en/09-shadow-vs-light-dom.md +0 -174
  120. package/docs/en/11-layouts.md +0 -113
  121. package/docs/en/12-auth-and-api.md +0 -124
  122. package/docs/en/16-bake-cookbook.md +0 -100
  123. package/docs/en/17-shadow-dom-forms.md +0 -192
  124. package/docs/fr/00-the-mado-way.md +0 -85
  125. package/docs/fr/01-routing.md +0 -120
  126. package/docs/fr/02-project-layout.md +0 -84
  127. package/docs/fr/03-static-bake.md +0 -289
  128. package/docs/fr/04-ide-setup.md +0 -162
  129. package/docs/fr/05-why-mado.md +0 -193
  130. package/docs/fr/06-for-backenders.md +0 -438
  131. package/docs/fr/07-llm-pitfalls.md +0 -625
  132. package/docs/fr/08-llm-zero-history-test.md +0 -38
  133. package/docs/fr/09-shadow-vs-light-dom.md +0 -65
  134. package/docs/fr/10-app-architecture.md +0 -138
  135. package/docs/fr/11-layouts.md +0 -47
  136. package/docs/fr/12-auth-and-api.md +0 -76
  137. package/docs/fr/13-deployment.md +0 -57
  138. package/docs/fr/14-testing.md +0 -41
  139. package/docs/fr/15-error-handling.md +0 -50
  140. package/docs/fr/16-bake-cookbook.md +0 -88
  141. package/docs/fr/17-shadow-dom-forms.md +0 -196
  142. package/docs/fr/18-api-freeze-map.md +0 -63
  143. package/docs/fr/19-reactivity-ordering.md +0 -97
  144. package/docs/fr/20-v1-stability.md +0 -88
  145. package/docs/fr/README.md +0 -27
  146. package/docs/ru/00-the-mado-way.md +0 -84
  147. package/docs/ru/01-routing.md +0 -119
  148. package/docs/ru/02-project-layout.md +0 -84
  149. package/docs/ru/03-static-bake.md +0 -250
  150. package/docs/ru/04-ide-setup.md +0 -144
  151. package/docs/ru/05-why-mado.md +0 -193
  152. package/docs/ru/06-for-backenders.md +0 -428
  153. package/docs/ru/07-llm-pitfalls.md +0 -624
  154. package/docs/ru/08-llm-zero-history-test.md +0 -57
  155. package/docs/ru/09-shadow-vs-light-dom.md +0 -63
  156. package/docs/ru/10-app-architecture.md +0 -152
  157. package/docs/ru/11-layouts.md +0 -47
  158. package/docs/ru/12-auth-and-api.md +0 -75
  159. package/docs/ru/13-deployment.md +0 -66
  160. package/docs/ru/14-testing.md +0 -50
  161. package/docs/ru/15-error-handling.md +0 -56
  162. package/docs/ru/16-bake-cookbook.md +0 -95
  163. package/docs/ru/17-shadow-dom-forms.md +0 -193
  164. package/docs/ru/18-api-freeze-map.md +0 -64
  165. package/docs/ru/19-reactivity-ordering.md +0 -95
  166. package/docs/ru/20-v1-stability.md +0 -82
  167. package/docs/ru/README.md +0 -25
  168. package/docs/uk/00-the-mado-way.md +0 -82
  169. package/docs/uk/01-routing.md +0 -76
  170. package/docs/uk/02-project-layout.md +0 -73
  171. package/docs/uk/03-static-bake.md +0 -48
  172. package/docs/uk/04-ide-setup.md +0 -26
  173. package/docs/uk/05-why-mado.md +0 -34
  174. package/docs/uk/06-for-backenders.md +0 -55
  175. package/docs/uk/07-llm-pitfalls.md +0 -145
  176. package/docs/uk/08-llm-zero-history-test.md +0 -34
  177. package/docs/uk/09-shadow-vs-light-dom.md +0 -58
  178. package/docs/uk/10-app-architecture.md +0 -97
  179. package/docs/uk/11-layouts.md +0 -47
  180. package/docs/uk/12-auth-and-api.md +0 -70
  181. package/docs/uk/13-deployment.md +0 -40
  182. package/docs/uk/14-testing.md +0 -34
  183. package/docs/uk/15-error-handling.md +0 -32
  184. package/docs/uk/16-bake-cookbook.md +0 -36
  185. package/docs/uk/17-shadow-dom-forms.md +0 -193
  186. package/docs/uk/18-api-freeze-map.md +0 -61
  187. package/docs/uk/19-reactivity-ordering.md +0 -95
  188. package/docs/uk/20-v1-stability.md +0 -83
  189. package/docs/uk/README.md +0 -27
  190. package/starters/default/src/modules/home/not-found.page.ts +0 -11
  191. /package/docs/en/{15-error-handling.md → 21-error-handling.md} +0 -0
  192. /package/docs/en/{19-reactivity-ordering.md → 31-reactivity-ordering.md} +0 -0
  193. /package/starters/{default → modular}/.editorconfig +0 -0
  194. /package/starters/{default → modular}/eslint.config.mjs +0 -0
  195. /package/starters/{default → modular}/public/favicon.svg +0 -0
  196. /package/starters/{default → modular}/src/modules/auth/_contracts/auth-api.types.ts +0 -0
  197. /package/starters/{default → modular}/src/modules/auth/auth.connector.ts +0 -0
  198. /package/starters/{default → modular}/src/modules/auth/auth.guard.ts +0 -0
  199. /package/starters/{default → modular}/src/modules/auth/auth.public.ts +0 -0
  200. /package/starters/{default → modular}/src/modules/auth/auth.routes.ts +0 -0
  201. /package/starters/{default → modular}/src/modules/auth/auth.service.ts +0 -0
  202. /package/starters/{default → modular}/src/modules/auth/auth.types.ts +0 -0
  203. /package/starters/{default → modular}/src/modules/auth/login.page.ts +0 -0
  204. /package/starters/{default → modular}/src/modules/billing/_contracts/stripe.types.ts +0 -0
  205. /package/starters/{default → modular}/src/modules/billing/api/stripe.connector.ts +0 -0
  206. /package/starters/{default → modular}/src/modules/billing/billing.public.ts +0 -0
  207. /package/starters/{default → modular}/src/modules/billing/billing.routes.ts +0 -0
  208. /package/starters/{default → modular}/src/modules/billing/billing.types.ts +0 -0
  209. /package/starters/{default → modular}/src/modules/billing/components/invoice-status-badge.component.ts +0 -0
  210. /package/starters/{default → modular}/src/modules/billing/data/invoices.resource.ts +0 -0
  211. /package/starters/{default → modular}/src/modules/billing/pages/invoice-detail.page.ts +0 -0
  212. /package/starters/{default → modular}/src/shared/http/http-client.ts +0 -0
  213. /package/starters/{default → modular}/src/shared/http/http-error.ts +0 -0
  214. /package/starters/{default → modular}/src/shared/http/interceptors.ts +0 -0
  215. /package/starters/{default → modular}/src/shared/lib/format-date.ts +0 -0
  216. /package/starters/{default → modular}/src/shared/styles/content.css +0 -0
  217. /package/starters/{default → modular}/src/shared/styles/reset.css +0 -0
  218. /package/starters/{default → modular}/src/shared/styles/shell.css +0 -0
  219. /package/starters/{default → modular}/src/shared/styles/tokens.css +0 -0
  220. /package/starters/{default → modular}/src/shared/ui/x-button.component.ts +0 -0
  221. /package/starters/{default → modular}/src/shared/ui/x-spinner.component.ts +0 -0
@@ -0,0 +1,654 @@
1
+ /**
2
+ * Snapshot capture: HTTP policy.
3
+ *
4
+ * Mado's static snapshot is a "strict" capture by default — any failed
5
+ * fetch surfaces as a snapshot failure unless it falls into a small,
6
+ * explicit allow-list. The intent is to catch broken deployments at
7
+ * build time, not at first-paint in production.
8
+ *
9
+ * FATAL (snapshot fails):
10
+ * - the main document
11
+ * - any script / module
12
+ * - any stylesheet
13
+ * - any fetch tracked by the runtime (resource(), mutation())
14
+ * - any custom-element definition referenced from the rendered DOM
15
+ * - any route module dynamic import
16
+ *
17
+ * IGNORED (warning at most):
18
+ * - /favicon.ico, /favicon.svg, /robots.txt (often missing in
19
+ * development; user owns the production copy)
20
+ * - data: URLs (inlined, not network traffic)
21
+ *
22
+ * QUALITY HINTS (timeout → diagnostic, snapshot proceeds):
23
+ * - document.fonts.ready (cap: 5s)
24
+ * - requestAnimationFrame paint frames (cap: 1s)
25
+ *
26
+ * To intentionally allow an optional resource to fail without breaking
27
+ * the snapshot, host it through one of the ignored paths or remove it
28
+ * from the initial render. There is no per-element "this can 404" escape
29
+ * hatch yet; if you need one, the place to add it is
30
+ * `isIgnorableResourceUrl()` below.
31
+ */
32
+ import { existsSync } from "node:fs";
33
+
34
+ const KNOWN_CHROMIUM_PATHS = [
35
+ "/usr/bin/google-chrome",
36
+ "/usr/bin/google-chrome-stable",
37
+ "/usr/bin/chromium",
38
+ "/usr/bin/chromium-browser",
39
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
40
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
41
+ "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
42
+ "C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
43
+ "C:\\Program Files\\Chromium\\Application\\chrome.exe",
44
+ ];
45
+
46
+ export async function captureStaticRoutes(options) {
47
+ const { chromium } = await importPlaywright();
48
+ const browser = await launchBrowser(chromium, options);
49
+ const captured = [];
50
+
51
+ try {
52
+ const browserVersion =
53
+ typeof browser.version === "function" ? browser.version() : null;
54
+ if (browserVersion) {
55
+ console.log(`[static] browser: chromium ${browserVersion}`);
56
+ }
57
+ for (const record of options.records) {
58
+ const html = await captureRoute(browser, record, options);
59
+ captured.push({ ...record, html });
60
+ }
61
+ } finally {
62
+ await browser.close();
63
+ }
64
+
65
+ return captured;
66
+ }
67
+
68
+ /**
69
+ * Join a Mado-canonical base (`"/"` or `"/prefix/"`) with a route
70
+ * pathname so the capture browser navigates to the same URL shape the
71
+ * deployed app will serve.
72
+ *
73
+ * withBase("/", "/docs") === "/docs"
74
+ * withBase("/mado/", "/docs") === "/mado/docs"
75
+ * withBase("/mado/", "/") === "/mado/"
76
+ */
77
+ function withBaseLocal(base, pathname) {
78
+ const b = base && base !== "/" ? base : "/";
79
+ const p = pathname || "/";
80
+ const abs = p.startsWith("/") ? p : "/" + p;
81
+ if (b === "/") return abs;
82
+ if (abs === "/") return b.slice(0, -1) || "/";
83
+ return b + abs.slice(1);
84
+ }
85
+
86
+ async function captureRoute(browser, record, options) {
87
+ const context = await browser.newContext({ serviceWorkers: "block" });
88
+ const page = await context.newPage();
89
+ const pageErrors = [];
90
+ const failedRequests = [];
91
+ const consoleErrors = [];
92
+
93
+ page.on("pageerror", (err) => pageErrors.push(err.message));
94
+ page.on("requestfailed", (request) => {
95
+ if (isIgnorableResourceUrl(request.url())) return;
96
+ failedRequests.push(
97
+ `${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`.trim(),
98
+ );
99
+ });
100
+ page.on("response", (response) => {
101
+ if (response.status() < 400) return;
102
+ if (isIgnorableResourceUrl(response.url())) return;
103
+ failedRequests.push(`${response.request().method()} ${response.url()} ${response.status()}`);
104
+ });
105
+ page.on("console", (msg) => {
106
+ if (msg.type() !== "error") return;
107
+ const text = msg.text();
108
+ if (isIgnorableConsoleError(text)) return;
109
+ consoleErrors.push(text);
110
+ });
111
+
112
+ try {
113
+ // Navigate through the capture server using the real deployment URL
114
+ // shape: base-prefixed pathname. The runtime sees the same
115
+ // `location.pathname` it would see on the production CDN, so the
116
+ // router's stripBase()/withBase() codepaths are exercised exactly
117
+ // once per snapshot.
118
+ const browserPath = withBaseLocal(options.base ?? "/", record.pathname);
119
+ await page.goto(`${options.serverOrigin}${browserPath}`, {
120
+ waitUntil: "domcontentloaded",
121
+ timeout: options.timeout,
122
+ });
123
+
124
+ await waitForMadoStability(page, record, options.timeout);
125
+
126
+ // Web fonts that never resolve must not block the snapshot. Fonts
127
+ // are best-effort: if `document.fonts.ready` does not settle within
128
+ // the cap, we proceed and let the missing-font warning surface
129
+ // through the existing console-message listener.
130
+ await runWithTimeout(
131
+ page.evaluate(async () => {
132
+ if (document.fonts?.ready) await document.fonts.ready;
133
+ }),
134
+ options.fontsTimeout ?? 5_000,
135
+ `[mado:static] ${record.pathname}: document.fonts.ready did not settle in time; capturing without font metrics.`,
136
+ );
137
+
138
+ // Two paint frames so any rAF-driven layout/style effect can flush
139
+ // before we serialize the DOM. Bounded to prevent a stalled tab from
140
+ // hanging the pipeline.
141
+ await runWithTimeout(
142
+ page.evaluate(
143
+ () =>
144
+ new Promise((resolve) =>
145
+ requestAnimationFrame(() => requestAnimationFrame(resolve)),
146
+ ),
147
+ ),
148
+ options.paintTimeout ?? 1_000,
149
+ `[mado:static] ${record.pathname}: paint frames did not flush in time; capturing anyway.`,
150
+ );
151
+
152
+ const undefinedCustomElements = await collectUndefinedCustomElements(page);
153
+ if (undefinedCustomElements.length > 0) {
154
+ throw new Error(
155
+ `[mado:static] ${record.pathname}: unresolved custom elements: ` +
156
+ undefinedCustomElements.join(", "),
157
+ );
158
+ }
159
+
160
+ if (pageErrors.length || consoleErrors.length || failedRequests.length) {
161
+ const diagnostics = await runtimeDiagnostics(page);
162
+ throw new Error(
163
+ formatRouteFailure(record, {
164
+ diagnostics,
165
+ pageErrors,
166
+ consoleErrors,
167
+ failedRequests,
168
+ }),
169
+ );
170
+ }
171
+
172
+ return await serializeDocument(page, {
173
+ appId: options.appId ?? "app",
174
+ serverOrigin: options.serverOrigin,
175
+ baseUrl: options.baseUrl,
176
+ site: options.site ?? "",
177
+ base: options.base ?? "/",
178
+ pathname: record.pathname,
179
+ });
180
+ } finally {
181
+ await context.close();
182
+ }
183
+ }
184
+
185
+ async function waitForMadoStability(page, record, timeout) {
186
+ try {
187
+ await withTimeout(
188
+ page.evaluate(async () => {
189
+ const runtime = window.__MADO_STATIC__;
190
+ if (!runtime) {
191
+ throw new Error("window.__MADO_STATIC__ was not installed.");
192
+ }
193
+ // Runtime exposes phase-bounded timeouts internally
194
+ // (routeReady, resources). The outer `timeout` here is a
195
+ // safety net for the whole sequence.
196
+ await runtime.whenStable();
197
+ }),
198
+ timeout,
199
+ );
200
+ } catch (err) {
201
+ const diagnostics = await runtimeDiagnostics(page);
202
+ throw new Error(
203
+ formatRouteFailure(record, {
204
+ diagnostics,
205
+ pageErrors: [err.message],
206
+ consoleErrors: [],
207
+ failedRequests: [],
208
+ }),
209
+ );
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Best-effort timeout wrapper that swallows timeouts (logging a
215
+ * diagnostic) instead of failing the snapshot. Used for fonts and paint
216
+ * frames — both are render-quality hints, not correctness checks.
217
+ */
218
+ async function runWithTimeout(promise, timeout, swallowMessage) {
219
+ let timer;
220
+ try {
221
+ await Promise.race([
222
+ promise,
223
+ new Promise((_, reject) => {
224
+ timer = setTimeout(
225
+ () => reject(new Error("timeout")),
226
+ timeout,
227
+ );
228
+ }),
229
+ ]);
230
+ } catch (err) {
231
+ if (err && err.message === "timeout") {
232
+ console.warn(swallowMessage);
233
+ return;
234
+ }
235
+ throw err;
236
+ } finally {
237
+ clearTimeout(timer);
238
+ }
239
+ }
240
+
241
+ async function runtimeDiagnostics(page) {
242
+ try {
243
+ return await page.evaluate(() => window.__MADO_STATIC__?.diagnostics?.() ?? null);
244
+ } catch {
245
+ return null;
246
+ }
247
+ }
248
+
249
+ async function collectUndefinedCustomElements(page) {
250
+ return page.evaluate(() => {
251
+ const names = new Set();
252
+ const visit = (root) => {
253
+ for (const el of root.querySelectorAll("*")) {
254
+ const name = el.localName;
255
+ if (name.includes("-") && !customElements.get(name)) names.add(name);
256
+ if (el.shadowRoot) visit(el.shadowRoot);
257
+ }
258
+ };
259
+ visit(document);
260
+ return [...names].sort();
261
+ });
262
+ }
263
+
264
+ async function serializeDocument(page, options) {
265
+ return page.evaluate((opts) => {
266
+ const { appId, serverOrigin, baseUrl, site, base, pathname } = opts;
267
+ const added = [];
268
+ // Strip both the modern attribute marker and the legacy inline script
269
+ // (older snapshots may still contain it). The seed <script> is
270
+ // intentionally kept so the production client can consume it on boot.
271
+ document.documentElement.removeAttribute("data-mado-static-capture");
272
+ for (const script of document.querySelectorAll("script[data-mado-static-mode]")) {
273
+ script.remove();
274
+ }
275
+
276
+ const app = document.getElementById(appId);
277
+ if (app) app.setAttribute("data-mado-static", "");
278
+
279
+ // ---- canonical / og:url fallback ----
280
+ //
281
+ // If page.head() did not produce these, derive them from `site + base
282
+ // + pathname` so static documents always carry the production URL.
283
+ // We only fill in absent values; explicit user-provided canonical /
284
+ // og:url wins.
285
+ if (site) {
286
+ const absoluteUrl = buildAbsoluteUrl(site, base, pathname);
287
+ ensureCanonical(absoluteUrl);
288
+ ensureOgUrl(absoluteUrl);
289
+ }
290
+
291
+ // Walk every open shadow root reachable from the document and run the
292
+ // same normalization in each. Mado canonical components live inside
293
+ // shadow trees, so a Light-DOM-only walker would lose form state and
294
+ // CSS for the very components DSD is meant to serialize.
295
+ const openShadowRoots = collectShadowRoots(document);
296
+ for (const root of [document, ...openShadowRoots]) {
297
+ normalizeDomStateIn(root);
298
+ }
299
+ materializeShadowStyles(openShadowRoots, added);
300
+
301
+ // Verification: count live open shadow roots before serialization and
302
+ // assert the same count of <template shadowrootmode> in the output.
303
+ // A drop indicates a host that was not opened with `serializable: true`.
304
+ const expectedShadowRoots = openShadowRoots.length;
305
+
306
+ let html;
307
+ try {
308
+ if (typeof document.documentElement.getHTML !== "function") {
309
+ throw new Error(
310
+ "document.documentElement.getHTML() is unavailable in this browser.",
311
+ );
312
+ }
313
+ const attrs = [...document.documentElement.attributes]
314
+ .map((attr) => `${attr.name}="${escapeAttr(attr.value)}"`)
315
+ .join(" ");
316
+ html =
317
+ "<!doctype html>\n" +
318
+ `<html${attrs ? ` ${attrs}` : ""}>` +
319
+ document.documentElement.getHTML({
320
+ serializableShadowRoots: true,
321
+ }) +
322
+ "</html>";
323
+ } finally {
324
+ for (const node of added) node.remove();
325
+ }
326
+
327
+ const serializedShadowRoots = (
328
+ html.match(/<template[^>]+shadowrootmode\b/g) ?? []
329
+ ).length;
330
+ if (serializedShadowRoots < expectedShadowRoots) {
331
+ throw new Error(
332
+ `[mado:static] DSD count mismatch: ${expectedShadowRoots} open ` +
333
+ `shadow roots present but ${serializedShadowRoots} serialized. ` +
334
+ "Make sure all custom elements attach their shadow root with " +
335
+ "{ mode: 'open', serializable: true }.",
336
+ );
337
+ }
338
+
339
+ return html.split(serverOrigin).join(baseUrl.replace(/\/$/, ""));
340
+
341
+ function buildAbsoluteUrl(origin, baseValue, route) {
342
+ const left = origin.replace(/\/+$/, "");
343
+ const b = baseValue && baseValue !== "/" ? baseValue : "/";
344
+ const p = route || "/";
345
+ const abs = p.startsWith("/") ? p : "/" + p;
346
+ let combined;
347
+ if (b === "/") {
348
+ combined = left + abs;
349
+ } else if (abs === "/") {
350
+ // "/mado/" + "/" → "/mado" (canonical pathnames have no trailing slash
351
+ // except for the bare root).
352
+ combined = left + b.slice(0, -1);
353
+ } else {
354
+ combined = left + b + abs.slice(1);
355
+ }
356
+ // Strip a single trailing slash unless the whole URL is the bare
357
+ // origin or origin + base root.
358
+ if (
359
+ combined.length > left.length + 1 &&
360
+ combined.endsWith("/")
361
+ ) {
362
+ combined = combined.slice(0, -1);
363
+ }
364
+ return combined;
365
+ }
366
+
367
+ function ensureCanonical(absoluteUrl) {
368
+ const existing = document.head.querySelectorAll('link[rel="canonical"]');
369
+ // De-duplicate: keep the first, drop the rest. A second canonical
370
+ // is always wrong and confuses search engines.
371
+ for (let i = 1; i < existing.length; i++) existing[i].remove();
372
+ const first = existing[0];
373
+ if (first) {
374
+ const href = first.getAttribute("href") || "";
375
+ if (!isUsableAbsoluteUrl(href)) {
376
+ first.setAttribute("href", absoluteUrl);
377
+ }
378
+ return;
379
+ }
380
+ const link = document.createElement("link");
381
+ link.setAttribute("rel", "canonical");
382
+ link.setAttribute("href", absoluteUrl);
383
+ // Mark the static fallback as Mado-managed so the runtime
384
+ // `applyHead()` (which clears every `[data-mado-head]` before
385
+ // writing the new metadata) can remove stale canonicals after an
386
+ // SPA navigation into a page that does not declare its own.
387
+ link.setAttribute("data-mado-head", "static");
388
+ document.head.appendChild(link);
389
+ }
390
+
391
+ function ensureOgUrl(absoluteUrl) {
392
+ const existing = document.head.querySelectorAll('meta[property="og:url"]');
393
+ for (let i = 1; i < existing.length; i++) existing[i].remove();
394
+ const first = existing[0];
395
+ if (first) {
396
+ const content = first.getAttribute("content") || "";
397
+ if (!isUsableAbsoluteUrl(content)) {
398
+ first.setAttribute("content", absoluteUrl);
399
+ }
400
+ return;
401
+ }
402
+ const meta = document.createElement("meta");
403
+ meta.setAttribute("property", "og:url");
404
+ meta.setAttribute("content", absoluteUrl);
405
+ // Same rationale as ensureCanonical: handing the tag to
406
+ // applyHead()'s clean-slate selector prevents stale OG URLs from
407
+ // surviving a navigation into a page without explicit head().
408
+ meta.setAttribute("data-mado-head", "static");
409
+ document.head.appendChild(meta);
410
+ }
411
+
412
+ function isUsableAbsoluteUrl(value) {
413
+ if (!value) return false;
414
+ let parsed;
415
+ try {
416
+ parsed = new URL(value);
417
+ } catch {
418
+ return false;
419
+ }
420
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
421
+ if (parsed.hostname === "localhost") return false;
422
+ if (parsed.hostname === "127.0.0.1") return false;
423
+ if (parsed.hostname === "::1") return false;
424
+ // Reject the capture server's own origin: that proves head() emitted
425
+ // a value derived from `location.origin` instead of the public site.
426
+ if (parsed.origin === serverOrigin) return false;
427
+ return true;
428
+ }
429
+
430
+ function collectShadowRoots(start) {
431
+ const out = [];
432
+ const stack = [start];
433
+ while (stack.length) {
434
+ const root = stack.pop();
435
+ for (const el of root.querySelectorAll("*")) {
436
+ if (el.shadowRoot) {
437
+ out.push(el.shadowRoot);
438
+ stack.push(el.shadowRoot);
439
+ }
440
+ }
441
+ }
442
+ return out;
443
+ }
444
+
445
+ function materializeShadowStyles(roots, out) {
446
+ for (const shadow of roots) {
447
+ for (const sheet of shadow.adoptedStyleSheets ?? []) {
448
+ const text = stylesheetText(sheet);
449
+ if (!text) continue;
450
+ const style = document.createElement("style");
451
+ style.setAttribute("data-mado-static-style", stableHash(text));
452
+ style.textContent = text;
453
+ shadow.insertBefore(style, shadow.firstChild);
454
+ out.push(style);
455
+ }
456
+ }
457
+ }
458
+
459
+ function stylesheetText(sheet) {
460
+ try {
461
+ return [...sheet.cssRules].map((rule) => rule.cssText).join("\n");
462
+ } catch {
463
+ return "";
464
+ }
465
+ }
466
+
467
+ function normalizeDomStateIn(root) {
468
+ for (const input of root.querySelectorAll("input")) {
469
+ if (input.type === "password") continue;
470
+ if (input.type === "checkbox" || input.type === "radio") {
471
+ if (input.checked) input.setAttribute("checked", "");
472
+ else input.removeAttribute("checked");
473
+ continue;
474
+ }
475
+ if (input.value !== input.getAttribute("value")) {
476
+ input.setAttribute("value", input.value);
477
+ }
478
+ }
479
+
480
+ for (const textarea of root.querySelectorAll("textarea")) {
481
+ textarea.textContent = textarea.value;
482
+ }
483
+
484
+ for (const select of root.querySelectorAll("select")) {
485
+ for (const option of select.options) {
486
+ if (option.selected) option.setAttribute("selected", "");
487
+ else option.removeAttribute("selected");
488
+ }
489
+ }
490
+
491
+ for (const details of root.querySelectorAll("details")) {
492
+ if (details.open) details.setAttribute("open", "");
493
+ else details.removeAttribute("open");
494
+ }
495
+ }
496
+
497
+ function stableHash(text) {
498
+ let hash = 0x811c9dc5;
499
+ for (let i = 0; i < text.length; i++) {
500
+ hash ^= text.charCodeAt(i);
501
+ hash = Math.imul(hash, 0x01000193);
502
+ }
503
+ return (hash >>> 0).toString(36);
504
+ }
505
+
506
+ function escapeAttr(value) {
507
+ return String(value)
508
+ .replace(/&/g, "&amp;")
509
+ .replace(/"/g, "&quot;")
510
+ .replace(/</g, "&lt;")
511
+ .replace(/>/g, "&gt;");
512
+ }
513
+ }, options);
514
+ }
515
+
516
+ async function importPlaywright() {
517
+ try {
518
+ return await import("playwright-core");
519
+ } catch {
520
+ throw new Error(
521
+ "[mado:static] package 'playwright-core' is required.\n" +
522
+ "Install it as a dev dependency in this project:\n" +
523
+ " npm i -D playwright-core",
524
+ );
525
+ }
526
+ }
527
+
528
+ /**
529
+ * The browser pulls `/favicon.ico` automatically and many user shells do
530
+ * not provide one; a 404 there must not fail the snapshot. Likewise any
531
+ * resource the user explicitly marked as optional (data:, devtools).
532
+ */
533
+ function isIgnorableResourceUrl(url) {
534
+ if (!url) return false;
535
+ if (url.startsWith("data:")) return true;
536
+ try {
537
+ const u = new URL(url);
538
+ if (u.pathname === "/favicon.ico") return true;
539
+ if (u.pathname === "/favicon.svg") return true;
540
+ if (u.pathname === "/robots.txt") return true;
541
+ return false;
542
+ } catch {
543
+ return false;
544
+ }
545
+ }
546
+
547
+ function isIgnorableConsoleError(text) {
548
+ if (!text) return false;
549
+ // Browser-level "Failed to load resource" lines without context. The
550
+ // matching response/requestfailed listeners already classify the URL.
551
+ if (/Failed to load resource/i.test(text) && !/\bmado\b/i.test(text)) {
552
+ return true;
553
+ }
554
+ return false;
555
+ }
556
+
557
+ /**
558
+ * Browser launch order (explicit-first, Playwright-managed second):
559
+ *
560
+ * 1. --browser-path / MADO_BROWSER_PATH (operator override)
561
+ * 2. --browser-channel / MADO_BROWSER_CHANNEL (operator override)
562
+ * 3. chromium.launch() (Playwright-managed Chromium)
563
+ * 4. channel: "chrome" (system Chrome stable)
564
+ * 5. known system executable paths (Linux/macOS/Windows)
565
+ *
566
+ * Playwright recommends installing browsers via its own CLI in CI
567
+ * (`npx playwright install --with-deps chromium`) so the browser
568
+ * revision is version-pinned against the running Playwright. We honour
569
+ * that recommendation by trying the managed Chromium *before* any
570
+ * branded Chrome so that pinned-environment CI is deterministic.
571
+ */
572
+ async function launchBrowser(chromium, options) {
573
+ const explicitPath = options.browserPath || process.env.MADO_BROWSER_PATH;
574
+ if (explicitPath) {
575
+ return chromium.launch({
576
+ executablePath: explicitPath,
577
+ headless: true,
578
+ });
579
+ }
580
+
581
+ const explicitChannel = options.browserChannel || process.env.MADO_BROWSER_CHANNEL;
582
+ if (explicitChannel) {
583
+ return chromium.launch({ channel: explicitChannel, headless: true });
584
+ }
585
+
586
+ const errors = [];
587
+
588
+ // Playwright-managed Chromium first: matches Playwright's protocol and
589
+ // gives deterministic DSD serialization independent of any branded
590
+ // Chrome that happens to be installed.
591
+ try {
592
+ return await chromium.launch({ headless: true });
593
+ } catch (err) {
594
+ errors.push(err.message);
595
+ }
596
+
597
+ // System Chrome stable second: useful for "does this still work with
598
+ // the latest branded Chrome?" smoke checks.
599
+ try {
600
+ return await chromium.launch({ channel: "chrome", headless: true });
601
+ } catch (err) {
602
+ errors.push(err.message);
603
+ }
604
+
605
+ for (const path of KNOWN_CHROMIUM_PATHS) {
606
+ if (!existsSync(path)) continue;
607
+ try {
608
+ return await chromium.launch({ executablePath: path, headless: true });
609
+ } catch (err) {
610
+ errors.push(err.message);
611
+ }
612
+ }
613
+
614
+ throw new Error(
615
+ "[mado:static] No compatible Chromium browser was found.\n\n" +
616
+ "Provide one of:\n" +
617
+ " npx playwright install --with-deps chromium (preferred in CI)\n" +
618
+ " mado static --browser-channel chrome\n" +
619
+ " mado static --browser-path /path/to/chrome\n" +
620
+ " MADO_BROWSER_CHANNEL=chrome\n" +
621
+ " MADO_BROWSER_PATH=/path/to/chrome\n\n" +
622
+ (errors.length ? `Last launch error: ${errors.at(-1)}` : ""),
623
+ );
624
+ }
625
+
626
+ function withTimeout(promise, timeout) {
627
+ let timer;
628
+ return Promise.race([
629
+ promise,
630
+ new Promise((_, reject) => {
631
+ timer = setTimeout(() => reject(new Error(`timed out after ${timeout}ms`)), timeout);
632
+ }),
633
+ ]).finally(() => clearTimeout(timer));
634
+ }
635
+
636
+ function formatRouteFailure(record, details) {
637
+ const lines = [
638
+ `[mado:static] failed to capture ${record.pathname}`,
639
+ `route: ${record.pattern}`,
640
+ ];
641
+ if (details.diagnostics) {
642
+ lines.push(`router state: ${details.diagnostics.lastRouterState ?? "unknown"}`);
643
+ lines.push(
644
+ `pending: ${details.diagnostics.pending?.join(", ") || "none"}`,
645
+ );
646
+ if (details.diagnostics.errors?.length) {
647
+ lines.push(`runtime errors: ${details.diagnostics.errors.join(" | ")}`);
648
+ }
649
+ }
650
+ if (details.pageErrors.length) lines.push(`page errors: ${details.pageErrors.join(" | ")}`);
651
+ if (details.consoleErrors.length) lines.push(`console errors: ${details.consoleErrors.join(" | ")}`);
652
+ if (details.failedRequests.length) lines.push(`failed requests: ${details.failedRequests.join(" | ")}`);
653
+ return lines.join("\n");
654
+ }