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