@invisibleloop/pulse 0.1.21

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 (344) hide show
  1. package/.claude/commands/build-page.md +59 -0
  2. package/.claude/commands/new-doc-page.md +45 -0
  3. package/.claude/commands/verify.md +52 -0
  4. package/.claude/pulse-checklist.md +111 -0
  5. package/.claude/settings.local.json +102 -0
  6. package/.github/workflows/ci.yml +22 -0
  7. package/.github/workflows/publish.yml +41 -0
  8. package/.pulse/load-reports/home/1773432711417.json +22 -0
  9. package/CLAUDE.md +383 -0
  10. package/README.md +95 -0
  11. package/docs/.claude/pulse-checklist.md +111 -0
  12. package/docs/public/.pulse-ui-version +1 -0
  13. package/docs/public/dist/accessibility.boot-5DVTARJU.js +115 -0
  14. package/docs/public/dist/actions.boot-P66HKQEM.js +164 -0
  15. package/docs/public/dist/auth.boot-IMAJAUPH.js +140 -0
  16. package/docs/public/dist/caching.boot-DVR6KDE7.js +53 -0
  17. package/docs/public/dist/components--accordion.boot-3HVKMNWC.js +11 -0
  18. package/docs/public/dist/components--alert.boot-GCEXOZAC.js +6 -0
  19. package/docs/public/dist/components--app-badge.boot-DVT3GCHJ.js +6 -0
  20. package/docs/public/dist/components--avatar.boot-PSW24EVA.js +5 -0
  21. package/docs/public/dist/components--badge.boot-TYDY2RMK.js +7 -0
  22. package/docs/public/dist/components--banner.boot-EI5PZSZK.js +7 -0
  23. package/docs/public/dist/components--breadcrumbs.boot-SMA2E2GO.js +34 -0
  24. package/docs/public/dist/components--button.boot-J54BQM2E.js +23 -0
  25. package/docs/public/dist/components--card.boot-PZGNDIB6.js +138 -0
  26. package/docs/public/dist/components--carousel.boot-TP6LPFZZ.js +12 -0
  27. package/docs/public/dist/components--charts.boot-2EOYQWKL.js +108 -0
  28. package/docs/public/dist/components--checkbox.boot-DS5BSL6T.js +54 -0
  29. package/docs/public/dist/components--cluster.boot-HHVIBBJG.js +9 -0
  30. package/docs/public/dist/components--code-window.boot-2GR2DV33.js +20 -0
  31. package/docs/public/dist/components--container.boot-7LOOGK2K.js +5 -0
  32. package/docs/public/dist/components--cta.boot-FSNZ5YRT.js +11 -0
  33. package/docs/public/dist/components--divider.boot-3NI2C3QG.js +6 -0
  34. package/docs/public/dist/components--empty.boot-YX2UR3PV.js +7 -0
  35. package/docs/public/dist/components--feature.boot-MUD7NSUO.js +13 -0
  36. package/docs/public/dist/components--fieldset.boot-J7BYHMKF.js +19 -0
  37. package/docs/public/dist/components--fileupload.boot-NIKVTTPD.js +52 -0
  38. package/docs/public/dist/components--footer.boot-EYUK5FRG.js +14 -0
  39. package/docs/public/dist/components--grid.boot-URDQVDDR.js +59 -0
  40. package/docs/public/dist/components--heading.boot-BPQKU43E.js +44 -0
  41. package/docs/public/dist/components--hero.boot-4RAPRGAB.js +17 -0
  42. package/docs/public/dist/components--icons.boot-ZITNU5JP.js +68 -0
  43. package/docs/public/dist/components--image.boot-XEEGHQZF.js +19 -0
  44. package/docs/public/dist/components--input.boot-SGASZG5K.js +7 -0
  45. package/docs/public/dist/components--list.boot-W3XC5MHD.js +55 -0
  46. package/docs/public/dist/components--media.boot-5VFIETZO.js +13 -0
  47. package/docs/public/dist/components--modal.boot-RZUYXBN2.js +47 -0
  48. package/docs/public/dist/components--nav.boot-ODBOHU7O.js +33 -0
  49. package/docs/public/dist/components--pricing.boot-4AQ4ZVBY.js +21 -0
  50. package/docs/public/dist/components--progress.boot-GHAGYZOK.js +30 -0
  51. package/docs/public/dist/components--prose.boot-QANJL6JI.js +67 -0
  52. package/docs/public/dist/components--pullquote.boot-Q2WMNAZU.js +22 -0
  53. package/docs/public/dist/components--radio.boot-TJRDQ2OL.js +75 -0
  54. package/docs/public/dist/components--rating.boot-QBAN6DEL.js +38 -0
  55. package/docs/public/dist/components--search.boot-PXH5O5AG.js +17 -0
  56. package/docs/public/dist/components--section.boot-AQGIYHWW.js +12 -0
  57. package/docs/public/dist/components--segmented.boot-BEVTKEJO.js +33 -0
  58. package/docs/public/dist/components--select.boot-47X5RHOC.js +10 -0
  59. package/docs/public/dist/components--slider.boot-PSRRX7XL.js +47 -0
  60. package/docs/public/dist/components--spinner.boot-MZ5MO2OH.js +22 -0
  61. package/docs/public/dist/components--stack.boot-DI4NJXBF.js +9 -0
  62. package/docs/public/dist/components--stat.boot-QMFUWBQT.js +9 -0
  63. package/docs/public/dist/components--stepper.boot-34PP2NEV.js +22 -0
  64. package/docs/public/dist/components--table.boot-FCQGSFIQ.js +11 -0
  65. package/docs/public/dist/components--testimonial.boot-DWQPDKYG.js +11 -0
  66. package/docs/public/dist/components--textarea.boot-QVXLBOJ5.js +4 -0
  67. package/docs/public/dist/components--timeline.boot-26LN52P2.js +95 -0
  68. package/docs/public/dist/components--toggle.boot-IQQEI76S.js +29 -0
  69. package/docs/public/dist/components--tooltip.boot-LGHCO6NN.js +9 -0
  70. package/docs/public/dist/components.boot-SE6PQ4P7.js +103 -0
  71. package/docs/public/dist/config.boot-DTRRWUE6.js +126 -0
  72. package/docs/public/dist/constraints.boot-DUHDZBMC.js +71 -0
  73. package/docs/public/dist/deploy.boot-SLAD3NI2.js +163 -0
  74. package/docs/public/dist/docs-8e3d4b5c.css +1 -0
  75. package/docs/public/dist/extending.boot-UA3CN243.js +159 -0
  76. package/docs/public/dist/faq.boot-6EQAWLQR.js +43 -0
  77. package/docs/public/dist/getting-started.boot-TDKIFL5U.js +86 -0
  78. package/docs/public/dist/guard.boot-AUHAWTG4.js +80 -0
  79. package/docs/public/dist/home.boot-BVQXRH32.js +383 -0
  80. package/docs/public/dist/how-it-works.boot-LTWAKWKW.js +104 -0
  81. package/docs/public/dist/hydration.boot-JRM6IPJL.js +78 -0
  82. package/docs/public/dist/images.boot-M6ZVKTZS.js +80 -0
  83. package/docs/public/dist/manifest.json +94 -0
  84. package/docs/public/dist/meta.boot-7NXGPHR4.js +79 -0
  85. package/docs/public/dist/mutations.boot-F6F43UDX.js +79 -0
  86. package/docs/public/dist/navigation.boot-AOXWS3ZF.js +57 -0
  87. package/docs/public/dist/performance.boot-C3UPCOBK.js +98 -0
  88. package/docs/public/dist/persist.boot-WT32PQOQ.js +61 -0
  89. package/docs/public/dist/project-structure.boot-FB3LRVJ4.js +63 -0
  90. package/docs/public/dist/prompt-examples.boot-YKR4VDK4.js +31 -0
  91. package/docs/public/dist/pulse-ui-81a85c03.css +1 -0
  92. package/docs/public/dist/raw-responses.boot-M4KA5YXL.js +104 -0
  93. package/docs/public/dist/routing.boot-FNX5FDGH.js +70 -0
  94. package/docs/public/dist/runtime-B73WLANC.js +1 -0
  95. package/docs/public/dist/runtime-KO4BHUQ3.js +49 -0
  96. package/docs/public/dist/runtime-L2HNXIHW.js +59 -0
  97. package/docs/public/dist/runtime-QFURDKA2.js +5 -0
  98. package/docs/public/dist/runtime-UVPXO4IR.js +375 -0
  99. package/docs/public/dist/runtime-VMJA3Z4N.js +10 -0
  100. package/docs/public/dist/runtime-ZJ4FXT5O.js +11 -0
  101. package/docs/public/dist/server-api.boot-K7X3LCFB.js +219 -0
  102. package/docs/public/dist/server-data.boot-Y7HQYC4R.js +157 -0
  103. package/docs/public/dist/slash-commands.boot-V2UV7OW2.js +26 -0
  104. package/docs/public/dist/spec.boot-2WU7ZHCV.js +159 -0
  105. package/docs/public/dist/state.boot-B24GUE3R.js +73 -0
  106. package/docs/public/dist/store.boot-TLIB4XHH.js +150 -0
  107. package/docs/public/dist/streaming.boot-W2DZSMW4.js +80 -0
  108. package/docs/public/dist/stripe.boot-QN3C2GEL.js +164 -0
  109. package/docs/public/dist/supabase.boot-BG4XXLZE.js +303 -0
  110. package/docs/public/dist/testing.boot-6U4WKMTE.js +130 -0
  111. package/docs/public/dist/validation.boot-PQHYGW5B.js +100 -0
  112. package/docs/public/docs.css +2020 -0
  113. package/docs/public/menu.js +83 -0
  114. package/docs/public/pulse-ui.css +2739 -0
  115. package/docs/public/pulse-ui.js +236 -0
  116. package/docs/server.js +192 -0
  117. package/docs/src/lib/component-page.js +47 -0
  118. package/docs/src/lib/highlight.js +255 -0
  119. package/docs/src/lib/layout.js +131 -0
  120. package/docs/src/lib/metrics-store.js +6 -0
  121. package/docs/src/lib/nav.js +159 -0
  122. package/docs/src/lib/stats.js +81 -0
  123. package/docs/src/pages/accessibility.js +157 -0
  124. package/docs/src/pages/actions.js +191 -0
  125. package/docs/src/pages/auth.js +177 -0
  126. package/docs/src/pages/caching.js +95 -0
  127. package/docs/src/pages/components/accordion.js +48 -0
  128. package/docs/src/pages/components/alert.js +35 -0
  129. package/docs/src/pages/components/app-badge.js +41 -0
  130. package/docs/src/pages/components/avatar.js +35 -0
  131. package/docs/src/pages/components/badge.js +36 -0
  132. package/docs/src/pages/components/banner.js +45 -0
  133. package/docs/src/pages/components/breadcrumbs.js +94 -0
  134. package/docs/src/pages/components/button.js +84 -0
  135. package/docs/src/pages/components/card.js +225 -0
  136. package/docs/src/pages/components/carousel.js +72 -0
  137. package/docs/src/pages/components/charts.js +278 -0
  138. package/docs/src/pages/components/checkbox.js +129 -0
  139. package/docs/src/pages/components/cluster.js +47 -0
  140. package/docs/src/pages/components/code-window.js +57 -0
  141. package/docs/src/pages/components/container.js +40 -0
  142. package/docs/src/pages/components/cta.js +53 -0
  143. package/docs/src/pages/components/divider.js +37 -0
  144. package/docs/src/pages/components/empty.js +36 -0
  145. package/docs/src/pages/components/feature.js +60 -0
  146. package/docs/src/pages/components/fieldset.js +65 -0
  147. package/docs/src/pages/components/fileupload.js +127 -0
  148. package/docs/src/pages/components/footer.js +58 -0
  149. package/docs/src/pages/components/grid.js +165 -0
  150. package/docs/src/pages/components/heading.js +107 -0
  151. package/docs/src/pages/components/hero.js +65 -0
  152. package/docs/src/pages/components/icons.js +285 -0
  153. package/docs/src/pages/components/image.js +71 -0
  154. package/docs/src/pages/components/input.js +51 -0
  155. package/docs/src/pages/components/list.js +112 -0
  156. package/docs/src/pages/components/media.js +51 -0
  157. package/docs/src/pages/components/modal.js +111 -0
  158. package/docs/src/pages/components/nav.js +86 -0
  159. package/docs/src/pages/components/pricing.js +68 -0
  160. package/docs/src/pages/components/progress.js +102 -0
  161. package/docs/src/pages/components/prose.js +111 -0
  162. package/docs/src/pages/components/pullquote.js +71 -0
  163. package/docs/src/pages/components/radio.js +194 -0
  164. package/docs/src/pages/components/rating.js +106 -0
  165. package/docs/src/pages/components/search.js +61 -0
  166. package/docs/src/pages/components/section.js +59 -0
  167. package/docs/src/pages/components/segmented.js +121 -0
  168. package/docs/src/pages/components/select.js +45 -0
  169. package/docs/src/pages/components/slider.js +114 -0
  170. package/docs/src/pages/components/spinner.js +73 -0
  171. package/docs/src/pages/components/stack.js +48 -0
  172. package/docs/src/pages/components/stat.js +55 -0
  173. package/docs/src/pages/components/stepper.js +66 -0
  174. package/docs/src/pages/components/table.js +45 -0
  175. package/docs/src/pages/components/testimonial.js +49 -0
  176. package/docs/src/pages/components/textarea.js +31 -0
  177. package/docs/src/pages/components/timeline.js +227 -0
  178. package/docs/src/pages/components/toggle.js +84 -0
  179. package/docs/src/pages/components/tooltip.js +48 -0
  180. package/docs/src/pages/components.js +204 -0
  181. package/docs/src/pages/config.js +193 -0
  182. package/docs/src/pages/constraints.js +99 -0
  183. package/docs/src/pages/deploy.js +233 -0
  184. package/docs/src/pages/extending.js +198 -0
  185. package/docs/src/pages/faq.js +96 -0
  186. package/docs/src/pages/getting-started.js +106 -0
  187. package/docs/src/pages/guard.js +121 -0
  188. package/docs/src/pages/home.js +401 -0
  189. package/docs/src/pages/how-it-works.js +183 -0
  190. package/docs/src/pages/hydration.js +98 -0
  191. package/docs/src/pages/images.js +121 -0
  192. package/docs/src/pages/meta.js +120 -0
  193. package/docs/src/pages/mutations.js +106 -0
  194. package/docs/src/pages/navigation.js +85 -0
  195. package/docs/src/pages/performance.js +157 -0
  196. package/docs/src/pages/persist.js +88 -0
  197. package/docs/src/pages/project-structure.js +90 -0
  198. package/docs/src/pages/prompt-examples.js +186 -0
  199. package/docs/src/pages/raw-responses.js +124 -0
  200. package/docs/src/pages/routing.js +99 -0
  201. package/docs/src/pages/server-api.js +281 -0
  202. package/docs/src/pages/server-data.js +185 -0
  203. package/docs/src/pages/slash-commands.js +55 -0
  204. package/docs/src/pages/spec.js +207 -0
  205. package/docs/src/pages/state.js +101 -0
  206. package/docs/src/pages/store.js +181 -0
  207. package/docs/src/pages/streaming.js +108 -0
  208. package/docs/src/pages/stripe.js +193 -0
  209. package/docs/src/pages/supabase.js +323 -0
  210. package/docs/src/pages/testing.js +198 -0
  211. package/docs/src/pages/validation.js +138 -0
  212. package/examples/contact.js +166 -0
  213. package/examples/counter.js +94 -0
  214. package/examples/dev.server.js +91 -0
  215. package/examples/examples.test.js +394 -0
  216. package/examples/pricing.js +244 -0
  217. package/examples/products.js +191 -0
  218. package/examples/quiz.js +208 -0
  219. package/examples/shared.js +78 -0
  220. package/examples/todos.js +162 -0
  221. package/package.json +75 -0
  222. package/public/.pulse-ui-version +1 -0
  223. package/public/chippy-bird.css +246 -0
  224. package/public/examples/contact.css +119 -0
  225. package/public/examples/counter.css +79 -0
  226. package/public/examples/pricing.css +132 -0
  227. package/public/examples/products.css +100 -0
  228. package/public/examples/quiz.css +200 -0
  229. package/public/examples/todos.css +137 -0
  230. package/public/favicon.ico +0 -0
  231. package/public/log-dashboard.css +383 -0
  232. package/public/pulse-ui.css +2740 -0
  233. package/public/pulse-ui.js +236 -0
  234. package/public/pulse.css +149 -0
  235. package/scripts/build.js +411 -0
  236. package/src/agent/checklist.md +111 -0
  237. package/src/agent/coverage-check.js +66 -0
  238. package/src/agent/guide-components.md +274 -0
  239. package/src/agent/guide-examples.md +54 -0
  240. package/src/agent/guide-routing.md +36 -0
  241. package/src/agent/guide-server.md +258 -0
  242. package/src/agent/guide-spec.md +103 -0
  243. package/src/agent/guide-styles.md +191 -0
  244. package/src/agent/guide.md +979 -0
  245. package/src/agent/identity.md +106 -0
  246. package/src/agent/workflow.md +108 -0
  247. package/src/cli/cli.test.js +82 -0
  248. package/src/cli/dev.js +195 -0
  249. package/src/cli/discover.js +113 -0
  250. package/src/cli/index.js +361 -0
  251. package/src/cli/load-report.js +91 -0
  252. package/src/cli/load-runner.js +121 -0
  253. package/src/cli/report-server.js +723 -0
  254. package/src/cli/report.js +116 -0
  255. package/src/cli/scaffold.archive.js +1371 -0
  256. package/src/cli/scaffold.js +349 -0
  257. package/src/cli/start.js +74 -0
  258. package/src/html.js +19 -0
  259. package/src/mcp/server.js +884 -0
  260. package/src/mcp/validate-worker.js +110 -0
  261. package/src/runtime/image.js +74 -0
  262. package/src/runtime/image.test.js +111 -0
  263. package/src/runtime/index.js +621 -0
  264. package/src/runtime/navigate.js +146 -0
  265. package/src/runtime/runtime.test.js +773 -0
  266. package/src/runtime/ssr.js +464 -0
  267. package/src/runtime/ssr.test.js +421 -0
  268. package/src/runtime/store.js +92 -0
  269. package/src/runtime/toast.js +163 -0
  270. package/src/server/index.js +1386 -0
  271. package/src/server/server.test.js +1248 -0
  272. package/src/spec/schema.js +428 -0
  273. package/src/spec/schema.test.js +291 -0
  274. package/src/store/index.js +102 -0
  275. package/src/store/store.test.js +210 -0
  276. package/src/testing/html.js +283 -0
  277. package/src/testing/index.js +249 -0
  278. package/src/testing/testing.test.js +450 -0
  279. package/src/ui/accordion.js +28 -0
  280. package/src/ui/alert.js +43 -0
  281. package/src/ui/app-badge.js +48 -0
  282. package/src/ui/avatar.js +47 -0
  283. package/src/ui/badge.js +24 -0
  284. package/src/ui/banner.js +26 -0
  285. package/src/ui/breadcrumbs.js +38 -0
  286. package/src/ui/button.js +66 -0
  287. package/src/ui/card.js +34 -0
  288. package/src/ui/carousel.js +59 -0
  289. package/src/ui/charts.js +321 -0
  290. package/src/ui/checkbox.js +65 -0
  291. package/src/ui/cluster.js +44 -0
  292. package/src/ui/code-window.js +39 -0
  293. package/src/ui/container.js +24 -0
  294. package/src/ui/cta.js +37 -0
  295. package/src/ui/divider.js +29 -0
  296. package/src/ui/empty.js +33 -0
  297. package/src/ui/feature.js +33 -0
  298. package/src/ui/fieldset.js +37 -0
  299. package/src/ui/fileupload.js +89 -0
  300. package/src/ui/footer.js +38 -0
  301. package/src/ui/grid.js +36 -0
  302. package/src/ui/heading.js +45 -0
  303. package/src/ui/hero.js +37 -0
  304. package/src/ui/icons.js +161 -0
  305. package/src/ui/index.js +89 -0
  306. package/src/ui/input.js +74 -0
  307. package/src/ui/list.js +36 -0
  308. package/src/ui/media.js +44 -0
  309. package/src/ui/modal.js +80 -0
  310. package/src/ui/nav.js +61 -0
  311. package/src/ui/pricing.js +56 -0
  312. package/src/ui/progress.js +62 -0
  313. package/src/ui/prose.js +29 -0
  314. package/src/ui/pullquote.js +34 -0
  315. package/src/ui/radio.js +102 -0
  316. package/src/ui/rating.js +93 -0
  317. package/src/ui/search.js +77 -0
  318. package/src/ui/section.js +69 -0
  319. package/src/ui/segmented.js +50 -0
  320. package/src/ui/select.js +77 -0
  321. package/src/ui/slider.js +84 -0
  322. package/src/ui/spinner.js +34 -0
  323. package/src/ui/stack.js +36 -0
  324. package/src/ui/stat.js +52 -0
  325. package/src/ui/stepper.js +46 -0
  326. package/src/ui/switch.js +57 -0
  327. package/src/ui/table.js +45 -0
  328. package/src/ui/testimonial.js +48 -0
  329. package/src/ui/textarea.js +72 -0
  330. package/src/ui/timeline.js +72 -0
  331. package/src/ui/tooltip.js +28 -0
  332. package/src/ui/ui.test.js +1241 -0
  333. package/src/ui/uiimage.js +65 -0
  334. package/tsconfig.json +13 -0
  335. package/types/html.d.ts +17 -0
  336. package/types/image.d.ts +70 -0
  337. package/types/index.d.ts +7 -0
  338. package/types/navigate.d.ts +38 -0
  339. package/types/runtime.d.ts +63 -0
  340. package/types/schema.d.ts +243 -0
  341. package/types/server.d.ts +145 -0
  342. package/types/ssr.d.ts +110 -0
  343. package/types/testing.d.ts +154 -0
  344. package/types/ui.d.ts +704 -0
@@ -0,0 +1,428 @@
1
+ /**
2
+ * Pulse 2 — Spec Schema
3
+ *
4
+ * This is the authoritative definition of a valid Pulse spec.
5
+ * Everything the runtime, SSR renderer, streaming layer, and AI layer
6
+ * are built against this contract.
7
+ *
8
+ * A spec is a plain JS object. It is the source of truth for a route.
9
+ * Human-readable code (React, Svelte, etc.) is generated FROM this — never the reverse.
10
+ */
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Types (JSDoc — no build step, no TypeScript compiler needed)
14
+ // ---------------------------------------------------------------------------
15
+
16
+ /**
17
+ * @typedef {Object} StreamConfig
18
+ * Shell segments render immediately on first flush.
19
+ * Deferred segments render after their server data resolves.
20
+ *
21
+ * @property {string[]} shell - Segment keys to render in the first flush
22
+ * @property {string[]} [deferred] - Segment keys to render after async data
23
+ */
24
+
25
+ /**
26
+ * @typedef {Object} ValidationRule
27
+ * Declared once. Enforced at both ends — browser before submit, server before action.
28
+ *
29
+ * @property {boolean} [required]
30
+ * @property {number} [minLength]
31
+ * @property {number} [maxLength]
32
+ * @property {number} [min]
33
+ * @property {number} [max]
34
+ * @property {'email'|'url'|'numeric'} [format]
35
+ * @property {RegExp} [pattern]
36
+ */
37
+
38
+ /**
39
+ * @typedef {Object} ActionConfig
40
+ * Actions cross the server/client boundary. They are always async.
41
+ * The runtime handles the network request — the spec just declares the intent.
42
+ *
43
+ * @property {boolean} [validate] - Run validation before executing. Default false.
44
+ * @property {function} run - async (state, serverState?) => void
45
+ * @property {function} onSuccess - (state) => Partial<state>
46
+ * @property {function} onError - (state, error) => Partial<state>
47
+ * @property {function} [onStart] - (state) => Partial<state> — optimistic update
48
+ */
49
+
50
+ /**
51
+ * @typedef {Object} PulseSpec
52
+ * The complete definition of a route.
53
+ *
54
+ * @property {string} route - URL pattern e.g. '/contact', '/products/:id'
55
+ * @property {StreamConfig} [stream] - Streaming priority config. Default: render all in shell.
56
+ *
57
+ * @property {Object.<string, function>} [server]
58
+ * Async functions executed on the server. Results serialised into the initial HTML.
59
+ * Each key becomes available as the second argument to view functions.
60
+ * e.g. { products: async (req) => db.products.findAll() }
61
+ *
62
+ * @property {Object} state
63
+ * Initial client state. Plain object — no special types, no proxies.
64
+ * This is the only mutable state in the system. All mutations go through
65
+ * the mutations map — never direct assignment.
66
+ *
67
+ * @property {Object.<string, ValidationRule>} [validation]
68
+ * Dot-notation paths into state mapped to validation rules.
69
+ * e.g. { 'fields.email': { required: true, format: 'email' } }
70
+ *
71
+ * @property {Object.<string, function>|function} view
72
+ * Pure functions: (clientState, serverState?) => HTML string.
73
+ * Can be a single function for simple components, or a map of named
74
+ * segments for routes that use streaming.
75
+ * MUST be pure — no side effects, no async, deterministic output.
76
+ *
77
+ * @property {Object.<string, function>} [mutations]
78
+ * Pure functions: (state, payload?) => Partial<state>
79
+ * Each key matches a data-event attribute on a DOM element.
80
+ * e.g. { increment: (state) => ({ count: state.count + 1 }) }
81
+ * Return only the keys that change — runtime merges with current state.
82
+ *
83
+ * @property {Object.<string, ActionConfig>} [actions]
84
+ * Async operations that cross the server/client boundary.
85
+ * Triggered by data-action attributes on form elements.
86
+ *
87
+ * @property {Object} [meta]
88
+ * Page metadata — title, description, og tags etc.
89
+ * Used by the SSR renderer to populate <head>.
90
+ * Any value can be a function (ctx) => value for per-request resolution,
91
+ * useful for multi-brand sites where title, styles, or og tags vary by domain.
92
+ * e.g. { title: (ctx) => ctx.brand.name, styles: (ctx) => ['/themes/' + ctx.brand.slug + '.css'] }
93
+ * theme: 'light' adds data-theme="light" to <body>, activating the built-in light token set.
94
+ *
95
+ * @property {Object} [constraints]
96
+ * Runtime constraints on state values.
97
+ * Applied after every mutation — state can never violate these.
98
+ * e.g. { 'count': { min: 0, max: 10 } }
99
+ *
100
+ * @property {string} [hydrate]
101
+ * Browser-importable path to this spec file (must have a default export).
102
+ * When set, the SSR renderer injects a bootstrap <script> that calls mount()
103
+ * after the server-rendered HTML arrives, resuming interactivity.
104
+ * e.g. '/examples/counter.js'
105
+ *
106
+ * @property {function} [guard]
107
+ * Optional async function called before server data fetchers on every request.
108
+ * Return { redirect: '/login' } to deny access and redirect the user.
109
+ * Return nothing (or undefined) to allow the request to proceed.
110
+ * e.g. async (ctx) => { if (!ctx.cookies.session) return { redirect: '/login' } }
111
+ */
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Validation
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Validate a spec object against the schema.
119
+ * Returns { valid: true } or { valid: false, errors: string[] }
120
+ *
121
+ * @param {unknown} spec
122
+ * @returns {{ valid: boolean, errors: string[] }}
123
+ */
124
+ export function validateSpec(spec) {
125
+ const errors = []
126
+ const warnings = []
127
+
128
+ if (!spec || typeof spec !== 'object') {
129
+ return { valid: false, errors: ['Spec must be a plain object'] }
130
+ }
131
+
132
+ // route
133
+ if (spec.route === undefined) {
134
+ warnings.push('spec.route is not set — the server cannot register this page without a route')
135
+ } else if (typeof spec.route !== 'string') {
136
+ errors.push('spec.route must be a string (e.g. "/contact")')
137
+ } else if (!spec.route.startsWith('/')) {
138
+ errors.push('spec.route must start with "/" (e.g. "/contact")')
139
+ }
140
+
141
+ // contentType — marks a raw content spec (RSS, sitemap, JSON API, etc.)
142
+ if (spec.contentType !== undefined) {
143
+ if (typeof spec.contentType !== 'string' || !spec.contentType.trim()) {
144
+ errors.push('spec.contentType must be a non-empty string (e.g. "application/rss+xml; charset=utf-8")')
145
+ }
146
+ if (typeof spec.render !== 'function') {
147
+ errors.push('spec.render is required when spec.contentType is set — (ctx, server) => string')
148
+ }
149
+ // Raw specs don't need state or view — skip those checks
150
+ return { valid: errors.length === 0, errors }
151
+ }
152
+
153
+ // state
154
+ if (spec.state === undefined) {
155
+ errors.push('spec.state is required — use {} if there is no client state')
156
+ } else if (typeof spec.state !== 'object' || Array.isArray(spec.state)) {
157
+ errors.push('spec.state must be a plain object')
158
+ }
159
+
160
+ // view
161
+ if (!spec.view) {
162
+ errors.push('spec.view is required')
163
+ } else if (typeof spec.view !== 'function' && typeof spec.view !== 'object') {
164
+ errors.push('spec.view must be a function or a map of named segment functions')
165
+ } else if (typeof spec.view === 'object') {
166
+ for (const [key, fn] of Object.entries(spec.view)) {
167
+ if (typeof fn !== 'function') {
168
+ errors.push(`spec.view.${key} must be a function`)
169
+ }
170
+ }
171
+ }
172
+
173
+ // stream
174
+ if (spec.stream) {
175
+ if (!Array.isArray(spec.stream.shell)) {
176
+ errors.push('spec.stream.shell must be an array of segment key strings')
177
+ }
178
+ if (spec.stream.deferred && !Array.isArray(spec.stream.deferred)) {
179
+ errors.push('spec.stream.deferred must be an array of segment key strings')
180
+ }
181
+ // ensure streamed segments exist in view
182
+ if (typeof spec.view === 'object') {
183
+ const allSegments = [
184
+ ...(spec.stream.shell || []),
185
+ ...(spec.stream.deferred || [])
186
+ ]
187
+ for (const seg of allSegments) {
188
+ if (!spec.view[seg]) {
189
+ errors.push(`spec.stream references "${seg}" but spec.view.${seg} is not defined`)
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // server
196
+ if (spec.server) {
197
+ if (typeof spec.server !== 'object') {
198
+ errors.push('spec.server must be a plain object of async functions')
199
+ } else {
200
+ for (const [key, fn] of Object.entries(spec.server)) {
201
+ if (typeof fn !== 'function') {
202
+ errors.push(`spec.server.${key} must be a function`)
203
+ }
204
+ }
205
+ }
206
+ }
207
+
208
+ // mutations
209
+ if (spec.mutations) {
210
+ if (typeof spec.mutations !== 'object') {
211
+ errors.push('spec.mutations must be a plain object of functions')
212
+ } else {
213
+ for (const [key, fn] of Object.entries(spec.mutations)) {
214
+ if (typeof fn !== 'function') {
215
+ errors.push(`spec.mutations.${key} must be a function`)
216
+ }
217
+ }
218
+ }
219
+ }
220
+
221
+ // actions
222
+ if (spec.actions) {
223
+ if (typeof spec.actions !== 'object') {
224
+ errors.push('spec.actions must be a plain object')
225
+ } else {
226
+ for (const [key, action] of Object.entries(spec.actions)) {
227
+ if (typeof action.run !== 'function') {
228
+ errors.push(`spec.actions.${key}.run must be an async function`)
229
+ }
230
+ if (typeof action.onSuccess !== 'function') {
231
+ errors.push(`spec.actions.${key}.onSuccess must be a function`)
232
+ }
233
+ if (typeof action.onError !== 'function') {
234
+ errors.push(`spec.actions.${key}.onError must be a function`)
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ // validation
241
+ if (spec.validation) {
242
+ if (typeof spec.validation !== 'object') {
243
+ errors.push('spec.validation must be a plain object')
244
+ } else {
245
+ const validRuleKeys = ['required', 'minLength', 'maxLength', 'min', 'max', 'format', 'pattern']
246
+ for (const [path, rules] of Object.entries(spec.validation)) {
247
+ if (typeof rules !== 'object') {
248
+ errors.push(`spec.validation["${path}"] must be a plain object of rules`)
249
+ continue
250
+ }
251
+ for (const key of Object.keys(rules)) {
252
+ if (!validRuleKeys.includes(key)) {
253
+ errors.push(`spec.validation["${path}"].${key} is not a recognised rule. Valid rules: ${validRuleKeys.join(', ')}`)
254
+ }
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ // constraints
261
+ if (spec.constraints) {
262
+ if (typeof spec.constraints !== 'object') {
263
+ errors.push('spec.constraints must be a plain object')
264
+ }
265
+ }
266
+
267
+ // persist
268
+ if (spec.persist !== undefined) {
269
+ if (!Array.isArray(spec.persist) || !spec.persist.every(k => typeof k === 'string')) {
270
+ errors.push('spec.persist must be an array of state key strings, e.g. [\'count\']')
271
+ }
272
+ }
273
+
274
+ // store — keys from the global store this page subscribes to
275
+ if (spec.store !== undefined) {
276
+ if (!Array.isArray(spec.store) || !spec.store.every(k => typeof k === 'string')) {
277
+ errors.push('spec.store must be an array of store key strings, e.g. [\'user\', \'settings\']')
278
+ }
279
+ }
280
+
281
+ // onViewError — fallback renderer called when view() throws
282
+ if (spec.onViewError !== undefined) {
283
+ if (typeof spec.onViewError !== 'function') {
284
+ errors.push('spec.onViewError must be a function — (err, state, serverState) => htmlString')
285
+ }
286
+ }
287
+
288
+ // methods — HTTP methods this page spec accepts (default GET + HEAD)
289
+ if (spec.methods !== undefined) {
290
+ const VALID_METHODS = ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
291
+ if (!Array.isArray(spec.methods) || !spec.methods.every(m => VALID_METHODS.includes(m.toUpperCase()))) {
292
+ errors.push(`spec.methods must be an array of HTTP method strings, e.g. ['GET', 'POST']. Valid values: ${VALID_METHODS.join(', ')}`)
293
+ }
294
+ }
295
+
296
+ // meta
297
+ if (spec.meta) {
298
+ if (typeof spec.meta !== 'object') {
299
+ errors.push('spec.meta must be a plain object')
300
+ } else if (spec.meta.schema !== undefined && typeof spec.meta.schema !== 'object') {
301
+ errors.push('spec.meta.schema must be a plain object (JSON-LD)')
302
+ }
303
+ }
304
+
305
+ // serverTtl — seconds to cache server data in-process
306
+ if (spec.serverTtl !== undefined) {
307
+ if (typeof spec.serverTtl !== 'number' || spec.serverTtl <= 0) {
308
+ errors.push('spec.serverTtl must be a positive number (seconds)')
309
+ }
310
+ }
311
+
312
+ // serverTimeout — ms before any server fetcher times out
313
+ if (spec.serverTimeout !== undefined) {
314
+ if (typeof spec.serverTimeout !== 'number' || spec.serverTimeout <= 0) {
315
+ errors.push('spec.serverTimeout must be a positive number (milliseconds)')
316
+ }
317
+ }
318
+
319
+ // guard — per-route authorization check
320
+ if (spec.guard !== undefined) {
321
+ if (typeof spec.guard !== 'function') {
322
+ errors.push('spec.guard must be a function — async (ctx) => { redirect?: string }')
323
+ }
324
+ }
325
+
326
+ // cache — HTTP cache-control for HTML responses
327
+ if (spec.cache !== undefined) {
328
+ if (typeof spec.cache !== 'object' || Array.isArray(spec.cache)) {
329
+ errors.push('spec.cache must be a plain object')
330
+ } else {
331
+ if (spec.cache.maxAge !== undefined && typeof spec.cache.maxAge !== 'number') {
332
+ errors.push('spec.cache.maxAge must be a number (seconds)')
333
+ }
334
+ if (spec.cache.staleWhileRevalidate !== undefined && typeof spec.cache.staleWhileRevalidate !== 'number') {
335
+ errors.push('spec.cache.staleWhileRevalidate must be a number (seconds)')
336
+ }
337
+ if (spec.cache.public !== undefined && typeof spec.cache.public !== 'boolean') {
338
+ errors.push('spec.cache.public must be a boolean')
339
+ }
340
+ }
341
+ }
342
+
343
+ // hydrate — required whenever there is any client interactivity
344
+ if (!spec.hydrate && !spec.contentType) {
345
+ if (spec.mutations || spec.actions || spec.persist) {
346
+ warnings.push('spec.hydrate is missing — pages with mutations, actions, or persist need hydrate set to their browser-importable path, or client interactivity will silently do nothing')
347
+ }
348
+ }
349
+
350
+ // meta quality
351
+ if (!spec.meta) {
352
+ warnings.push('spec.meta is missing — add at minimum title and description for SEO')
353
+ } else {
354
+ if (!spec.meta.title || (typeof spec.meta.title === 'string' && !spec.meta.title.trim())) {
355
+ warnings.push('spec.meta.title is missing')
356
+ }
357
+ const desc = spec.meta.description
358
+ if (!desc) {
359
+ warnings.push('spec.meta.description is missing — add a meaningful description for SEO')
360
+ } else if (typeof desc === 'string' && desc.trim() === 'Built with Pulse') {
361
+ warnings.push('spec.meta.description is still the default "Built with Pulse" — replace it with a real description')
362
+ }
363
+ }
364
+
365
+ return {
366
+ valid: errors.length === 0,
367
+ errors,
368
+ warnings,
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Helpers
374
+ // ---------------------------------------------------------------------------
375
+
376
+ /**
377
+ * Assert a spec is valid. Throws with all errors if not.
378
+ * Used by the runtime and SSR renderer on startup.
379
+ *
380
+ * @param {unknown} spec
381
+ * @throws {Error}
382
+ */
383
+ export function assertValidSpec(spec) {
384
+ const { valid, errors, warnings } = validateSpec(spec)
385
+ if (!valid) {
386
+ throw new Error(
387
+ `Invalid Pulse spec${spec?.route ? ` for route "${spec.route}"` : ''}:\n` +
388
+ errors.map(e => ` — ${e}`).join('\n')
389
+ )
390
+ }
391
+ if (warnings.length > 0) {
392
+ console.warn(
393
+ `Pulse spec warnings${spec?.route ? ` for route "${spec.route}"` : ''}:\n` +
394
+ warnings.map(w => ` ⚠ ${w}`).join('\n')
395
+ )
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Get all segment keys defined in a spec's view.
401
+ * For a function view, returns ['default'].
402
+ * For a segmented view, returns the object keys.
403
+ *
404
+ * @param {PulseSpec} spec
405
+ * @returns {string[]}
406
+ */
407
+ export function getViewSegments(spec) {
408
+ if (typeof spec.view === 'function') return ['default']
409
+ return Object.keys(spec.view)
410
+ }
411
+
412
+ /**
413
+ * Get the stream order for a spec.
414
+ * Returns { shell: string[], deferred: string[] }
415
+ * If no stream config, all segments are in shell.
416
+ *
417
+ * @param {PulseSpec} spec
418
+ * @returns {{ shell: string[], deferred: string[] }}
419
+ */
420
+ export function getStreamOrder(spec) {
421
+ if (!spec.stream) {
422
+ return { shell: getViewSegments(spec), deferred: [] }
423
+ }
424
+ return {
425
+ shell: spec.stream.shell || [],
426
+ deferred: spec.stream.deferred || []
427
+ }
428
+ }