@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,284 @@
1
+ import { existsSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ import { detectContext, getPackageRoot, resolveProjectPath } from "../_config.mjs";
5
+ import { assertJsonSerializable } from "./serialize.mjs";
6
+
7
+ /**
8
+ * Build-time route discovery for static snapshots.
9
+ *
10
+ * Uses Vite's SSR module loader as a *control plane only* — no templates
11
+ * or components are rendered in Node. Routing, flattening, param
12
+ * extraction and pathname materialisation reuse the canonical helpers
13
+ * exported by src/router/match.ts so the discovery pipeline never drifts
14
+ * out of sync with the runtime router.
15
+ */
16
+ export async function discoverStaticRoutes(options) {
17
+ const projectRoot = resolve(options.projectRoot);
18
+ const context = detectContext(projectRoot);
19
+ const entry = resolveProjectPath(projectRoot, options.entry ?? pickDefaultEntry(projectRoot));
20
+
21
+ if (!existsSync(entry)) {
22
+ // Throwing — rather than `process.exit()` — lets the outer
23
+ // `scripts/static.mjs` finalize its temp-directory cleanup before
24
+ // the process terminates.
25
+ throw new Error(
26
+ `[mado:static] entry not found: ${entry}\n` +
27
+ `[mado:static] expected src/app.routes.ts or src/routes.ts; ` +
28
+ `pass --entry <file> to override`,
29
+ );
30
+ }
31
+
32
+ installNodeDomStubs();
33
+
34
+ let createViteServer;
35
+ try {
36
+ ({ createServer: createViteServer } = await import("vite"));
37
+ } catch {
38
+ throw new Error(
39
+ "[mado:static] package 'vite' is required.\n" +
40
+ "[mado:static] Install it as a dev dependency in this project:\n" +
41
+ "[mado:static] npm i -D vite playwright-core",
42
+ );
43
+ }
44
+
45
+ // Two facts to keep in sync:
46
+ // 1. In-repo runs MUST resolve `@madojs/mado` to the live source so the
47
+ // framework can test itself without a build artefact.
48
+ // 2. App runs need our routing helpers loaded through SSR alongside the
49
+ // user manifest; we expose them via `__mado_match__` so we can ask
50
+ // for them via ssrLoadModule without colliding with user imports.
51
+ const sourceMatch = resolve(getPackageRoot(), "src/router/match.ts");
52
+ const aliases = {
53
+ __mado_match__: existsSync(sourceMatch)
54
+ ? sourceMatch
55
+ : resolve(getPackageRoot(), "dist/src/router/match.js"),
56
+ ...(context === "repo"
57
+ ? { "@madojs/mado": resolve(projectRoot, "src/index.ts") }
58
+ : {}),
59
+ };
60
+
61
+ const viteServer = await createViteServer({
62
+ root: projectRoot,
63
+ logLevel: "error",
64
+ server: { middlewareMode: true },
65
+ appType: "custom",
66
+ resolve: { alias: aliases },
67
+ });
68
+
69
+ try {
70
+ // Load the canonical routing utilities through the SAME Vite SSR loader
71
+ // that loads the user manifest, so both halves see one consistent copy
72
+ // of @madojs/mado. Try the source first (works in-repo) and fall back
73
+ // to a direct import for app contexts where the package may not be
74
+ // resolvable through Vite's SSR alias chain.
75
+ const { flatten, paramKeys, applyParams, isPage } = await loadCoreHelpers(viteServer);
76
+
77
+ const routesModule = await viteServer.ssrLoadModule(toViteId(entry));
78
+ const manifest = routesModule.manifest ?? routesModule.default?.manifest;
79
+ if (!manifest) {
80
+ throw new Error(
81
+ `${entry} must export const manifest = {...} (the same object passed to routes()).`,
82
+ );
83
+ }
84
+
85
+ const records = [];
86
+ const seen = new Map();
87
+ for (const [pattern, flat] of flatten(manifest)) {
88
+ const page = await resolvePage(flat.loader, pattern, isPage);
89
+ if (!page?.static) continue;
90
+
91
+ validateStaticRoute(pattern, flat, page, paramKeys);
92
+ for (const layoutLoader of flat.layouts) {
93
+ const layout = await resolvePage(layoutLoader, `${pattern} layout`, isPage);
94
+ if (layout?.guard) {
95
+ throw new Error(`[mado:static] ${pattern}: static routes cannot use guarded layouts.`);
96
+ }
97
+ }
98
+
99
+ const config = page.static === true ? {} : page.static;
100
+ const paramsList = await resolveParams(pattern, config, paramKeys);
101
+ for (const params of paramsList) {
102
+ const pathname = applyParams(pattern, params);
103
+ if (seen.has(pathname)) {
104
+ throw new Error(
105
+ `[mado:static] duplicate generated URL ${pathname} from ${pattern}; ` +
106
+ `already produced by ${seen.get(pathname)}.`,
107
+ );
108
+ }
109
+ seen.set(pathname, pattern);
110
+
111
+ const record = {
112
+ pattern,
113
+ pathname,
114
+ params,
115
+ };
116
+ if (config.initialData) {
117
+ const initialData = await config.initialData(params);
118
+ assertJsonSerializable(initialData, `${pattern} -> ${pathname}`);
119
+ record.initialData = initialData;
120
+ }
121
+ records.push(record);
122
+ }
123
+ }
124
+
125
+ records.sort((a, b) => a.pathname.localeCompare(b.pathname));
126
+ return { entry, records };
127
+ } finally {
128
+ await viteServer.close();
129
+ }
130
+ }
131
+
132
+ function validateStaticRoute(pattern, flat, page, paramKeys) {
133
+ if (pattern === "*") {
134
+ throw new Error("[mado:static] wildcard routes cannot be static.");
135
+ }
136
+ if (flat.guards.length > 0) {
137
+ throw new Error(`[mado:static] ${pattern}: static routes cannot inherit layout guards.`);
138
+ }
139
+ if (page.guard) {
140
+ throw new Error(`[mado:static] ${pattern}: guarded pages cannot be static.`);
141
+ }
142
+ if (pattern.includes("*")) {
143
+ throw new Error(`[mado:static] ${pattern}: wildcard routes cannot be static.`);
144
+ }
145
+ const keys = paramKeys(pattern);
146
+ if (keys.length > 0 && page.static === true) {
147
+ throw new Error(`[mado:static] ${pattern}: dynamic static routes must provide static.paths().`);
148
+ }
149
+ if (keys.length > 0 && !page.static.paths) {
150
+ throw new Error(`[mado:static] ${pattern}: dynamic static routes must provide static.paths().`);
151
+ }
152
+ }
153
+
154
+ async function resolveParams(pattern, config, paramKeys) {
155
+ const keys = paramKeys(pattern);
156
+ const paramsList = config.paths ? await config.paths() : [{}];
157
+ if (!Array.isArray(paramsList)) {
158
+ throw new Error(`[mado:static] ${pattern}: static.paths() must return an array.`);
159
+ }
160
+ if (keys.length === 0 && !config.paths) return [{}];
161
+ for (const params of paramsList) {
162
+ if (!params || typeof params !== "object" || Array.isArray(params)) {
163
+ throw new Error(`[mado:static] ${pattern}: static.paths() must return params objects.`);
164
+ }
165
+ for (const key of keys) {
166
+ if (params[key] == null) {
167
+ throw new Error(`[mado:static] ${pattern}: missing route param "${key}".`);
168
+ }
169
+ }
170
+ }
171
+ return paramsList;
172
+ }
173
+
174
+ async function loadCoreHelpers(viteServer) {
175
+ // The Vite resolve.alias bridge maps `__mado_match__` to the live source
176
+ // file of this package, so the user manifest and discovery share the
177
+ // exact same routing helper functions (same function identity).
178
+ try {
179
+ const mod = await viteServer.ssrLoadModule("__mado_match__");
180
+ if (mod?.flatten && mod?.paramKeys && mod?.applyParams && mod?.isPage) {
181
+ return mod;
182
+ }
183
+ } catch (err) {
184
+ // Try the build artefact for published-only installs.
185
+ const distPath = resolve(getPackageRoot(), "dist/src/router/match.js");
186
+ if (existsSync(distPath)) {
187
+ const mod = await import(distPath);
188
+ if (mod?.flatten && mod?.paramKeys && mod?.applyParams && mod?.isPage) {
189
+ return mod;
190
+ }
191
+ }
192
+ throw new Error(
193
+ `[mado:static] failed to load core routing helpers: ${err.message}`,
194
+ );
195
+ }
196
+ throw new Error(
197
+ "[mado:static] core routing helpers loaded but missing expected exports.",
198
+ );
199
+ }
200
+
201
+ async function resolvePage(loader, label, isPage) {
202
+ try {
203
+ const value = await loader();
204
+ if (isPage(value)) return value;
205
+ // Tolerate fixtures that hand-roll a plain object with _page: true
206
+ // (the dynamic-static-route validation test does this).
207
+ if (value && value._page === true) return value;
208
+ throw new Error("loader did not resolve to page({...}).");
209
+ } catch (err) {
210
+ throw new Error(`[mado:static] failed to load ${label}: ${err.message}`);
211
+ }
212
+ }
213
+
214
+ function installNodeDomStubs() {
215
+ if (globalThis.document && globalThis.customElements) return;
216
+
217
+ globalThis.location = new URL("http://localhost/");
218
+ globalThis.history = { pushState() {}, replaceState() {} };
219
+ globalThis.window = {
220
+ addEventListener() {},
221
+ removeEventListener() {},
222
+ dispatchEvent() {},
223
+ location: globalThis.location,
224
+ history: globalThis.history,
225
+ };
226
+ globalThis.document = {
227
+ adoptedStyleSheets: [],
228
+ createElement(tag) {
229
+ return {
230
+ tagName: String(tag).toUpperCase(),
231
+ setAttribute() {},
232
+ appendChild() {},
233
+ append() {},
234
+ remove() {},
235
+ style: {},
236
+ };
237
+ },
238
+ head: { appendChild() {}, querySelectorAll: () => [] },
239
+ body: { appendChild() {} },
240
+ addEventListener() {},
241
+ removeEventListener() {},
242
+ dispatchEvent() {},
243
+ querySelector: () => null,
244
+ querySelectorAll: () => [],
245
+ getElementById: () => null,
246
+ };
247
+ globalThis.customElements = {
248
+ define() {},
249
+ get() {
250
+ return undefined;
251
+ },
252
+ whenDefined() {
253
+ return Promise.resolve();
254
+ },
255
+ };
256
+ globalThis.HTMLElement = class {};
257
+ globalThis.Element = class {};
258
+ globalThis.Node = class {};
259
+ globalThis.Comment = class {};
260
+ globalThis.DocumentFragment = class {};
261
+ globalThis.CSSStyleSheet = class {
262
+ cssRules = [];
263
+ replaceSync(text) {
264
+ this.cssRules = text ? [{ cssText: String(text) }] : [];
265
+ }
266
+ };
267
+ globalThis.CSS = { supports: () => false };
268
+ globalThis.CSSRule = {};
269
+ globalThis.matchMedia = () => ({
270
+ matches: false,
271
+ addEventListener() {},
272
+ removeEventListener() {},
273
+ });
274
+ }
275
+
276
+ function pickDefaultEntry(projectRoot) {
277
+ const appRoutes = "src/app.routes.ts";
278
+ if (existsSync(resolve(projectRoot, appRoutes))) return appRoutes;
279
+ return "src/routes.ts";
280
+ }
281
+
282
+ function toViteId(path) {
283
+ return path.split("\\").join("/");
284
+ }
@@ -0,0 +1,141 @@
1
+ import { existsSync } from "node:fs";
2
+ import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+
5
+ import { addNoIndex, escapeXml } from "./serialize.mjs";
6
+
7
+ /**
8
+ * Stage a deterministic temp output for the snapshot pipeline.
9
+ *
10
+ * out/index.html ← Vite-built SPA shell, consumed as capture seed
11
+ * <os-temp>/spa.html ← noindexed copy, staged before promotion
12
+ * <os-temp>/routes/... ← per-route captured HTML, also staged
13
+ *
14
+ * Nothing inside `out/` is mutated until all routes capture cleanly. A
15
+ * mid-pipeline crash leaves the existing deployment untouched; only a
16
+ * verified, complete capture is promoted by `promoteCapturedRoutes()`
17
+ * and `promoteSpaShell()`.
18
+ */
19
+ export async function prepareStaticOutput(outDir) {
20
+ const shellPath = join(outDir, "index.html");
21
+ if (!existsSync(shellPath)) {
22
+ throw new Error(
23
+ `[mado:static] missing ${shellPath}. Run \`mado build\` or Vite build before \`mado static\`.`,
24
+ );
25
+ }
26
+
27
+ const shellHtml = await readFile(shellPath, "utf8");
28
+
29
+ // External temp directory — never leaks into `out/` on failure.
30
+ const { mkdtemp } = await import("node:fs/promises");
31
+ const { tmpdir } = await import("node:os");
32
+ const tempRoot = await mkdtemp(join(tmpdir(), "mado-static-"));
33
+ const routesDir = join(tempRoot, "routes");
34
+ await mkdir(routesDir, { recursive: true });
35
+
36
+ // Stage the SPA fallback in temp; promote later, only after all
37
+ // captures verified. Until then `out/_mado/spa.html` either does not
38
+ // exist (cold release) or holds the previous release's copy (re-run).
39
+ const stagedSpaPath = join(tempRoot, "spa.html");
40
+ await writeFile(stagedSpaPath, addNoIndex(shellHtml));
41
+
42
+ return { shellHtml, tempRoot, routesDir, stagedSpaPath };
43
+ }
44
+
45
+ export async function writeCapturedRoutes(routesDir, captured) {
46
+ for (const record of captured) {
47
+ const file = safeRouteFile(routesDir, record.pathname);
48
+ await mkdir(dirname(file), { recursive: true });
49
+ await writeFile(file, record.html);
50
+ }
51
+ }
52
+
53
+ export async function promoteCapturedRoutes({ outDir, routesDir, captured }) {
54
+ for (const record of captured) {
55
+ const source = safeRouteFile(routesDir, record.pathname);
56
+ const target = safeRouteFile(outDir, record.pathname);
57
+ await mkdir(dirname(target), { recursive: true });
58
+ await copyFile(source, target);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * Promote the staged SPA fallback shell into `out/_mado/spa.html` after
64
+ * all routes captured successfully. Called once at the end of the
65
+ * pipeline; any crash before this point leaves the previous shell (or
66
+ * no shell at all) in place.
67
+ */
68
+ export async function promoteSpaShell({ outDir, stagedSpaPath }) {
69
+ const target = join(outDir, "_mado", "spa.html");
70
+ await mkdir(dirname(target), { recursive: true });
71
+ await copyFile(stagedSpaPath, target);
72
+ }
73
+
74
+ /**
75
+ * Drop the build-time bridge `out/_mado/build.json` from the final
76
+ * deployment artifact. It is internal CLI plumbing (the
77
+ * `@madojs/mado/vite` plugin emits it so `mado static` can read the
78
+ * resolved Vite base/site without parsing `vite.config.ts`); leaving it
79
+ * in `out/` would leak the build pipeline's view of the project to
80
+ * production traffic.
81
+ */
82
+ export async function dropBuildBridge(outDir) {
83
+ await rm(join(outDir, "_mado", "build.json"), { force: true });
84
+ }
85
+
86
+ /**
87
+ * Files owned by `mado static`: the sitemap (derived from discovered
88
+ * static routes) and the SPA fallback shell.
89
+ *
90
+ * Files owned by `mado release` (so it can decide writeIfMissing
91
+ * semantics for user-customised deployments): 404.html, _headers,
92
+ * _redirects, and asset precompression.
93
+ */
94
+ export async function writeStaticDeploymentFiles({ outDir, records, site, base }) {
95
+ await writeFile(join(outDir, "sitemap.xml"), sitemap(records, site, base));
96
+ }
97
+
98
+ export async function cleanupTemp(tempRoot) {
99
+ await rm(tempRoot, { recursive: true, force: true });
100
+ }
101
+
102
+ /**
103
+ * Map a route pathname to an `index.html` file inside `root`, refusing
104
+ * any path that would traverse outside the deployment tree.
105
+ */
106
+ function safeRouteFile(root, pathname) {
107
+ const decoded = decodeURIComponent(pathname);
108
+ if (decoded.includes("\0") || decoded.includes("\\")) {
109
+ throw new Error(`[mado:static] illegal pathname: ${pathname}`);
110
+ }
111
+ const cleaned = decoded === "/" ? "" : decoded.replace(/^\/+/, "").replace(/\/+$/, "");
112
+ if (cleaned.split("/").some((seg) => seg === "..")) {
113
+ throw new Error(`[mado:static] pathname traversal not allowed: ${pathname}`);
114
+ }
115
+ const target = resolve(root, cleaned, "index.html");
116
+ const rootAbs = resolve(root) + sep;
117
+ if (!target.startsWith(rootAbs)) {
118
+ throw new Error(`[mado:static] pathname escapes deployment root: ${pathname}`);
119
+ }
120
+ // Use forward slashes inside join so cross-platform output is stable.
121
+ return target;
122
+ }
123
+
124
+ function sitemap(records, site, base) {
125
+ const origin = (site ?? "").replace(/\/+$/, "");
126
+ const prefix = ((base ?? "/") || "/").replace(/\/+$/, "");
127
+ return `<?xml version="1.0" encoding="UTF-8"?>
128
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
129
+ ${records
130
+ .map((record) => {
131
+ const pathname = record.pathname === "/" ? "" : record.pathname;
132
+ const loc = `${origin}${prefix}${pathname}` || "/";
133
+ return ` <url><loc>${escapeXml(loc)}</loc></url>`;
134
+ })
135
+ .join("\n")}
136
+ </urlset>
137
+ `;
138
+ }
139
+
140
+ // Silence the unused-import linter for the helpers we still re-export.
141
+ void relative;
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Strict JsonValue validator.
3
+ *
4
+ * Mirrors the public `JsonValue` type from src/page.ts: only null,
5
+ * booleans, finite numbers, strings, plain arrays and plain object trees
6
+ * are allowed. Forbidden shapes (Date, Map, Set, class instances, NaN,
7
+ * Infinity, undefined values, functions, symbols, bigints, cycles,
8
+ * non-plain prototypes) are reported with a path-aware error so a static
9
+ * route author can find the bad field immediately.
10
+ *
11
+ * [mado:static] /products/keyboard:
12
+ * seed.product.createdAt is Date; expected JsonValue.
13
+ *
14
+ * `JSON.stringify()` alone is too forgiving — Date coerces to a string,
15
+ * Map serialises as `{}`, undefined fields silently disappear — so the
16
+ * snapshot would look "fine" at build time but mismatch the runtime
17
+ * shape on the first SPA navigation. The custom walker stops the bad
18
+ * value before it ever reaches the snapshot HTML.
19
+ */
20
+ function describeNonJson(value) {
21
+ if (value === undefined) return "undefined";
22
+ if (value === null) return "null";
23
+ if (typeof value === "number" && !Number.isFinite(value)) {
24
+ return Number.isNaN(value) ? "NaN" : "Infinity";
25
+ }
26
+ if (typeof value === "bigint") return "bigint";
27
+ if (typeof value === "symbol") return "symbol";
28
+ if (typeof value === "function") return "function";
29
+ if (Array.isArray(value)) return "Array";
30
+ if (value instanceof Date) return "Date";
31
+ if (typeof Map !== "undefined" && value instanceof Map) return "Map";
32
+ if (typeof Set !== "undefined" && value instanceof Set) return "Set";
33
+ if (typeof RegExp !== "undefined" && value instanceof RegExp) return "RegExp";
34
+ if (typeof URL !== "undefined" && value instanceof URL) return "URL";
35
+ if (ArrayBuffer.isView?.(value)) return value.constructor?.name ?? "TypedArray";
36
+ if (value instanceof ArrayBuffer) return "ArrayBuffer";
37
+ if (value && typeof value === "object") {
38
+ const proto = Object.getPrototypeOf(value);
39
+ if (proto !== null && proto !== Object.prototype) {
40
+ return value.constructor?.name ?? "non-plain object";
41
+ }
42
+ return "object";
43
+ }
44
+ return typeof value;
45
+ }
46
+
47
+ function isPlainObject(value) {
48
+ if (value === null || typeof value !== "object") return false;
49
+ if (Array.isArray(value)) return false;
50
+ const proto = Object.getPrototypeOf(value);
51
+ return proto === null || proto === Object.prototype;
52
+ }
53
+
54
+ /**
55
+ * Walk a value and throw on the first non-JsonValue shape. `seen`
56
+ * tracks containers to catch cycles deterministically (Set keyed by
57
+ * identity).
58
+ */
59
+ function walkJsonValue(value, path, contextLabel, seen) {
60
+ if (value === null) return;
61
+ const t = typeof value;
62
+ if (t === "boolean" || t === "string") return;
63
+ if (t === "number") {
64
+ if (!Number.isFinite(value)) {
65
+ throw new Error(
66
+ `[mado:static] ${contextLabel}: ${path} is ${
67
+ Number.isNaN(value) ? "NaN" : "Infinity"
68
+ }; expected JsonValue.`,
69
+ );
70
+ }
71
+ return;
72
+ }
73
+ if (Array.isArray(value)) {
74
+ if (seen.has(value)) {
75
+ throw new Error(
76
+ `[mado:static] ${contextLabel}: ${path} contains a circular reference; expected JsonValue.`,
77
+ );
78
+ }
79
+ seen.add(value);
80
+ for (let i = 0; i < value.length; i++) {
81
+ walkJsonValue(value[i], `${path}[${i}]`, contextLabel, seen);
82
+ }
83
+ seen.delete(value);
84
+ return;
85
+ }
86
+ if (isPlainObject(value)) {
87
+ if (seen.has(value)) {
88
+ throw new Error(
89
+ `[mado:static] ${contextLabel}: ${path} contains a circular reference; expected JsonValue.`,
90
+ );
91
+ }
92
+ seen.add(value);
93
+ for (const key of Object.keys(value)) {
94
+ const child = value[key];
95
+ // `undefined` would silently disappear through JSON.stringify; we
96
+ // reject it explicitly so the schema stays honest. To omit a field
97
+ // simply do not include the key.
98
+ if (child === undefined) {
99
+ throw new Error(
100
+ `[mado:static] ${contextLabel}: ${path}.${key} is undefined; ` +
101
+ `expected JsonValue. Omit the key instead of assigning undefined.`,
102
+ );
103
+ }
104
+ walkJsonValue(child, `${path}.${key}`, contextLabel, seen);
105
+ }
106
+ seen.delete(value);
107
+ return;
108
+ }
109
+ throw new Error(
110
+ `[mado:static] ${contextLabel}: ${path} is ${describeNonJson(value)}; ` +
111
+ `expected JsonValue.`,
112
+ );
113
+ }
114
+
115
+ /**
116
+ * Throw if `value` is not a strict JsonValue (see walkJsonValue).
117
+ * `contextLabel` is included in every error so the discovery output can
118
+ * point a user at the failing route or pathname.
119
+ */
120
+ export function assertJsonSerializable(value, contextLabel) {
121
+ walkJsonValue(value, "seed", contextLabel, new Set());
122
+ }
123
+
124
+ export function serializeJsonForScript(value, contextLabel) {
125
+ assertJsonSerializable(value, contextLabel);
126
+ // The validator guarantees JSON.stringify cannot drop, coerce or
127
+ // silently expand anything, so the resulting string is exactly the
128
+ // shape page.head/load will receive on first client boot.
129
+ const json = JSON.stringify(value);
130
+ if (json === undefined) {
131
+ // Should be unreachable after assertJsonSerializable; defensive.
132
+ throw new Error(
133
+ `[mado:static] ${contextLabel}: top-level value cannot be serialized as JSON.`,
134
+ );
135
+ }
136
+ return json
137
+ .replace(/</g, "\\u003C")
138
+ .replace(/>/g, "\\u003E")
139
+ .replace(/&/g, "\\u0026")
140
+ .replace(/\u2028/g, "\\u2028")
141
+ .replace(/\u2029/g, "\\u2029");
142
+ }
143
+
144
+ /**
145
+ * Prepare the shell HTML for browser capture of a single route.
146
+ *
147
+ * Two CSP-safe markers are injected:
148
+ * 1. `data-mado-static-capture` on the <html> tag — the runtime checks
149
+ * that attribute (and removes it before serialization) instead of
150
+ * relying on an inline script that strict CSP would block.
151
+ * 2. `<script type="application/json" data-mado-static-data="${pathname}">`
152
+ * carrying the build-time seed. The runtime consumes it once, the
153
+ * serializer preserves it in the final snapshot, and the real client
154
+ * boot consumes it again on first load.
155
+ *
156
+ * The seed is URL-bound through the `data-mado-static-data` attribute so
157
+ * SPA navigations to other routes (or accidentally cached copies) cannot
158
+ * reuse a stale value.
159
+ */
160
+ export function injectSnapshotMode(html, record) {
161
+ let out = setHtmlAttribute(html, "data-mado-static-capture", "");
162
+ if ("initialData" in record) {
163
+ const seedScript =
164
+ `<script type="application/json" data-mado-static-data="${escapeAttr(record.pathname)}">` +
165
+ serializeJsonForScript(record.initialData, record.pathname) +
166
+ `</script>`;
167
+ out = injectAfterHeadOpen(out, seedScript);
168
+ }
169
+ return out;
170
+ }
171
+
172
+ function setHtmlAttribute(html, name, value) {
173
+ const match = /<html\b([^>]*)>/i.exec(html);
174
+ if (!match) return html;
175
+ const attrs = match[1] ?? "";
176
+ if (new RegExp(`\\b${name}\\b`, "i").test(attrs)) return html;
177
+ const replacement = `<html${attrs} ${name}="${escapeAttr(value)}">`;
178
+ return html.slice(0, match.index) + replacement + html.slice(match.index + match[0].length);
179
+ }
180
+
181
+ function escapeAttr(value) {
182
+ return String(value)
183
+ .replace(/&/g, "&amp;")
184
+ .replace(/"/g, "&quot;")
185
+ .replace(/</g, "&lt;")
186
+ .replace(/>/g, "&gt;");
187
+ }
188
+
189
+ export function addNoIndex(html) {
190
+ if (/<meta\s+[^>]*(?:name=["']robots["']|content=["'][^"']*noindex)/i.test(html)) {
191
+ return html;
192
+ }
193
+ return injectAfterHeadOpen(
194
+ html,
195
+ `<meta name="robots" content="noindex">`,
196
+ );
197
+ }
198
+
199
+ export function escapeXml(value) {
200
+ return String(value)
201
+ .replace(/&/g, "&amp;")
202
+ .replace(/</g, "&lt;")
203
+ .replace(/>/g, "&gt;")
204
+ .replace(/"/g, "&quot;");
205
+ }
206
+
207
+ function injectAfterHeadOpen(html, content) {
208
+ const match = /<head\b[^>]*>/i.exec(html);
209
+ if (!match) return content + html;
210
+ const at = match.index + match[0].length;
211
+ return html.slice(0, at) + content + html.slice(at);
212
+ }