@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,283 @@
1
+ /**
2
+ * Pulse Testing — Minimal HTML tokenizer and CSS selector engine
3
+ *
4
+ * No dependencies. Handles common HTML patterns well enough for test assertions.
5
+ * Supports: tag, .class, #id, [attr], [attr="value"] and combinations thereof.
6
+ * Does NOT support descendant combinators (div p) — match within el.findAll() instead.
7
+ */
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Constants
11
+ // ---------------------------------------------------------------------------
12
+
13
+ const VOID_TAGS = new Set([
14
+ 'area','base','br','col','embed','hr','img','input',
15
+ 'link','meta','param','source','track','wbr',
16
+ ])
17
+
18
+ // These tags contain raw text — don't parse their content as HTML
19
+ const RAW_TAGS = new Set(['script','style','noscript'])
20
+
21
+ // ---------------------------------------------------------------------------
22
+ // Tokenizer
23
+ // ---------------------------------------------------------------------------
24
+
25
+ /**
26
+ * Find the position of the closing > of a tag, respecting quoted attribute values.
27
+ * @param {string} html
28
+ * @param {number} from Index just after the opening <
29
+ */
30
+ function findTagEnd(html, from) {
31
+ let i = from
32
+ let inSingle = false, inDouble = false
33
+ while (i < html.length) {
34
+ const c = html[i]
35
+ if (c === '"' && !inSingle) inDouble = !inDouble
36
+ else if (c === "'" && !inDouble) inSingle = !inSingle
37
+ else if (c === '>' && !inSingle && !inDouble) return i
38
+ i++
39
+ }
40
+ return -1
41
+ }
42
+
43
+ /**
44
+ * Parse an attribute string into a plain object.
45
+ * Boolean attributes (no value) are stored as true.
46
+ *
47
+ * @param {string} str
48
+ * @returns {Record<string, string|true>}
49
+ */
50
+ function parseAttrs(str) {
51
+ const attrs = {}
52
+ // Matches: name name="val" name='val' name=val
53
+ const re = /([a-zA-Z_][\w\-:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]*)))?/g
54
+ let m
55
+ while ((m = re.exec(str)) !== null) {
56
+ const name = m[1].toLowerCase()
57
+ const val = m[2] !== undefined ? m[2]
58
+ : m[3] !== undefined ? m[3]
59
+ : m[4] !== undefined ? m[4]
60
+ : true
61
+ attrs[name] = val
62
+ }
63
+ return attrs
64
+ }
65
+
66
+ function decodeEntities(str) {
67
+ return str
68
+ .replace(/&amp;/g, '&')
69
+ .replace(/&lt;/g, '<')
70
+ .replace(/&gt;/g, '>')
71
+ .replace(/&quot;/g, '"')
72
+ .replace(/&#0?39;/g, "'")
73
+ .replace(/&apos;/g, "'")
74
+ .replace(/&nbsp;/g, ' ')
75
+ }
76
+
77
+ /**
78
+ * Tokenize an HTML string into a flat array of tokens.
79
+ *
80
+ * Token shapes:
81
+ * { type: 'open', tag, attrs, selfClose }
82
+ * { type: 'close', tag }
83
+ * { type: 'text', text }
84
+ *
85
+ * @param {string} html
86
+ * @returns {Array}
87
+ */
88
+ export function tokenize(html) {
89
+ const tokens = []
90
+ let i = 0
91
+
92
+ while (i < html.length) {
93
+ // Text node
94
+ if (html[i] !== '<') {
95
+ const next = html.indexOf('<', i)
96
+ const slice = next === -1 ? html.slice(i) : html.slice(i, next)
97
+ const text = decodeEntities(slice.trim())
98
+ if (text) tokens.push({ type: 'text', text })
99
+ i = next === -1 ? html.length : next
100
+ continue
101
+ }
102
+
103
+ const tagEnd = findTagEnd(html, i + 1)
104
+ if (tagEnd === -1) break
105
+
106
+ const raw = html.slice(i + 1, tagEnd).trim()
107
+
108
+ // Skip doctype, comments, CDATA, processing instructions
109
+ if (raw.startsWith('!') || raw.startsWith('?')) {
110
+ i = tagEnd + 1
111
+ continue
112
+ }
113
+
114
+ // Closing tag
115
+ if (raw.startsWith('/')) {
116
+ const tag = raw.slice(1).trim().split(/\s/)[0].toLowerCase()
117
+ tokens.push({ type: 'close', tag })
118
+ i = tagEnd + 1
119
+ continue
120
+ }
121
+
122
+ // Opening tag (possibly self-closing)
123
+ const hasSelfSlash = raw.endsWith('/')
124
+ const content = hasSelfSlash ? raw.slice(0, -1).trimEnd() : raw
125
+ const spaceIdx = content.search(/\s/)
126
+ const tag = (spaceIdx === -1 ? content : content.slice(0, spaceIdx)).toLowerCase()
127
+ const attrsStr = spaceIdx === -1 ? '' : content.slice(spaceIdx)
128
+ const attrs = parseAttrs(attrsStr)
129
+ const selfClose = hasSelfSlash || VOID_TAGS.has(tag)
130
+
131
+ tokens.push({ type: 'open', tag, attrs, selfClose })
132
+ if (selfClose) tokens.push({ type: 'close', tag })
133
+
134
+ i = tagEnd + 1
135
+
136
+ // Skip raw content of script/style
137
+ if (RAW_TAGS.has(tag) && !selfClose) {
138
+ const closeTag = `</${tag}`
139
+ const closeIdx = html.toLowerCase().indexOf(closeTag, i)
140
+ if (closeIdx !== -1) {
141
+ const closeTagEnd = html.indexOf('>', closeIdx)
142
+ tokens.push({ type: 'close', tag })
143
+ i = closeTagEnd === -1 ? html.length : closeTagEnd + 1
144
+ }
145
+ }
146
+ }
147
+
148
+ return tokens
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Selector parsing
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /**
156
+ * Parse a simple CSS selector string.
157
+ * Supports: tag .class #id [attr] [attr="value"] and combinations.
158
+ *
159
+ * @param {string} selector
160
+ * @returns {{ tag: string|null, id: string|null, classes: string[], attrs: Array<{name,value}> }}
161
+ */
162
+ export function parseSelector(selector) {
163
+ const result = { tag: null, id: null, classes: [], attrs: [] }
164
+ let s = selector.trim()
165
+
166
+ // Optional leading tag name
167
+ const tagMatch = s.match(/^([a-zA-Z][a-zA-Z0-9]*)/)
168
+ if (tagMatch) {
169
+ result.tag = tagMatch[1].toLowerCase()
170
+ s = s.slice(tagMatch[0].length)
171
+ }
172
+
173
+ // .class, #id, [attr], [attr="value"]
174
+ const partRe = /([.#[])([^\s.[#\]"'=]+)(?:="([^"]*)")?]?/g
175
+ let m
176
+ while ((m = partRe.exec(s)) !== null) {
177
+ const prefix = m[1]
178
+ if (prefix === '.') result.classes.push(m[2])
179
+ else if (prefix === '#') result.id = m[2]
180
+ else if (prefix === '[') result.attrs.push({ name: m[2].toLowerCase(), value: m[3] ?? null })
181
+ }
182
+
183
+ return result
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Matching
188
+ // ---------------------------------------------------------------------------
189
+
190
+ /**
191
+ * Test whether an 'open' token matches a parsed selector.
192
+ *
193
+ * @param {{ type, tag, attrs }} token
194
+ * @param {{ tag, id, classes, attrs }} sel
195
+ */
196
+ export function matchesToken(token, sel) {
197
+ if (token.type !== 'open') return false
198
+ if (sel.tag !== null && token.tag !== sel.tag) return false
199
+ if (sel.id !== null && token.attrs['id'] !== sel.id) return false
200
+ if (sel.classes.length) {
201
+ const cls = String(token.attrs['class'] || '').split(/\s+/)
202
+ if (!sel.classes.every(c => cls.includes(c))) return false
203
+ }
204
+ for (const { name, value } of sel.attrs) {
205
+ if (!(name in token.attrs)) return false
206
+ if (value !== null && token.attrs[name] !== value) return false
207
+ }
208
+ return true
209
+ }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Tree navigation
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * Return the slice of tokens representing the inner content of an open element.
217
+ * Handles nested same-tag elements correctly.
218
+ *
219
+ * @param {Array} tokens
220
+ * @param {number} openIdx Index of the 'open' token
221
+ * @returns {Array}
222
+ */
223
+ export function getInnerTokens(tokens, openIdx) {
224
+ if (tokens[openIdx].selfClose) return []
225
+ const tag = tokens[openIdx].tag
226
+ let depth = 1
227
+ let i = openIdx + 1
228
+ while (i < tokens.length && depth > 0) {
229
+ const tok = tokens[i]
230
+ if (tok.type === 'open' && tok.tag === tag && !tok.selfClose) depth++
231
+ else if (tok.type === 'close' && tok.tag === tag) depth--
232
+ i++
233
+ }
234
+ return tokens.slice(openIdx + 1, depth === 0 ? i - 1 : i)
235
+ }
236
+
237
+ /**
238
+ * Extract all text content from a token list, space-separated.
239
+ *
240
+ * @param {Array} tokens
241
+ * @returns {string}
242
+ */
243
+ export function extractText(tokens) {
244
+ return tokens
245
+ .filter(t => t.type === 'text')
246
+ .map(t => t.text)
247
+ .join(' ')
248
+ .replace(/\s+/g, ' ')
249
+ .trim()
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // Query API
254
+ // ---------------------------------------------------------------------------
255
+
256
+ /**
257
+ * Find the first token matching selector.
258
+ * @param {Array} tokens
259
+ * @param {string} selector
260
+ * @returns {{ token, index } | null}
261
+ */
262
+ export function findFirst(tokens, selector) {
263
+ const sel = parseSelector(selector)
264
+ for (let i = 0; i < tokens.length; i++) {
265
+ if (matchesToken(tokens[i], sel)) return { token: tokens[i], index: i }
266
+ }
267
+ return null
268
+ }
269
+
270
+ /**
271
+ * Find all tokens matching selector.
272
+ * @param {Array} tokens
273
+ * @param {string} selector
274
+ * @returns {Array<{ token, index }>}
275
+ */
276
+ export function findAll(tokens, selector) {
277
+ const sel = parseSelector(selector)
278
+ const results = []
279
+ for (let i = 0; i < tokens.length; i++) {
280
+ if (matchesToken(tokens[i], sel)) results.push({ token: tokens[i], index: i })
281
+ }
282
+ return results
283
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Pulse Testing — View testing helpers
3
+ *
4
+ * Render a spec's view in tests and assert on the HTML output.
5
+ * No DOM, no jsdom, no browser — pure Node.js.
6
+ *
7
+ * Two entry points:
8
+ * renderSync(spec, options?) — synchronous, calls view directly
9
+ * render(spec, options?) — async, runs real spec.server fetchers
10
+ *
11
+ * Both return a RenderResult with HTML query helpers.
12
+ */
13
+
14
+ import { tokenize, findFirst, findAll, getInnerTokens, extractText } from './html.js'
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Element
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Wrap a matched token as a queryable Element.
22
+ *
23
+ * @param {{ type, tag, attrs, selfClose }} token
24
+ * @param {Array} allTokens Full token list (for inner content extraction)
25
+ * @param {number} idx Position of this token in allTokens
26
+ */
27
+ function makeElement(token, allTokens, idx) {
28
+ const inner = getInnerTokens(allTokens, idx)
29
+
30
+ return {
31
+ /** Element tag name (lowercase) */
32
+ tag: token.tag,
33
+ /** Parsed attribute map. Boolean attrs (e.g. disabled) have value true. */
34
+ attrs: token.attrs,
35
+ /** All text content within this element, whitespace-collapsed. */
36
+ text: extractText(inner),
37
+
38
+ /**
39
+ * Get an attribute value. Returns '' for boolean attrs, null if absent.
40
+ * Mirrors DOM getAttribute() behaviour.
41
+ * @param {string} name
42
+ * @returns {string | null}
43
+ */
44
+ attr(name) {
45
+ const v = token.attrs[name.toLowerCase()]
46
+ if (v === undefined) return null
47
+ if (v === true) return ''
48
+ return String(v)
49
+ },
50
+
51
+ /**
52
+ * Find the first descendant matching selector. Returns null if not found.
53
+ * @param {string} selector
54
+ */
55
+ find(selector) {
56
+ const m = findFirst(inner, selector)
57
+ return m ? makeElement(m.token, inner, m.index) : null
58
+ },
59
+
60
+ /**
61
+ * Find all descendants matching selector.
62
+ * @param {string} selector
63
+ * @returns {Element[]}
64
+ */
65
+ findAll(selector) {
66
+ return findAll(inner, selector).map(({ token: t, index: i }) => makeElement(t, inner, i))
67
+ },
68
+
69
+ /**
70
+ * True if any descendant matches selector.
71
+ * @param {string} selector
72
+ */
73
+ has(selector) {
74
+ return findFirst(inner, selector) !== null
75
+ },
76
+ }
77
+ }
78
+
79
+ // ---------------------------------------------------------------------------
80
+ // RenderResult
81
+ // ---------------------------------------------------------------------------
82
+
83
+ /**
84
+ * Build a RenderResult from raw HTML + the state/server used.
85
+ *
86
+ * @param {string} html
87
+ * @param {object} state
88
+ * @param {object} server
89
+ */
90
+ function makeResult(html, state, server) {
91
+ const tokens = tokenize(html)
92
+
93
+ return {
94
+ /** Raw HTML string returned by the view function(s). */
95
+ html,
96
+ /** Client state used for rendering. */
97
+ state,
98
+ /** Server state used for rendering. */
99
+ server,
100
+
101
+ /** All text content in the output, whitespace-collapsed. */
102
+ text() {
103
+ return extractText(tokens)
104
+ },
105
+
106
+ /**
107
+ * True if any element in the output matches selector.
108
+ * @param {string} selector
109
+ */
110
+ has(selector) {
111
+ return findFirst(tokens, selector) !== null
112
+ },
113
+
114
+ /**
115
+ * Find the first element matching selector. Returns null if not found.
116
+ * @param {string} selector
117
+ * @returns {Element | null}
118
+ */
119
+ find(selector) {
120
+ const m = findFirst(tokens, selector)
121
+ return m ? makeElement(m.token, tokens, m.index) : null
122
+ },
123
+
124
+ /**
125
+ * Find the first element matching selector. Throws if not found.
126
+ * Use this when the element must exist — it gives a clear failure message.
127
+ * @param {string} selector
128
+ * @returns {Element}
129
+ */
130
+ get(selector) {
131
+ const el = this.find(selector)
132
+ if (!el) throw new Error(`Element not found: "${selector}"`)
133
+ return el
134
+ },
135
+
136
+ /**
137
+ * Find all elements matching selector.
138
+ * @param {string} selector
139
+ * @returns {Element[]}
140
+ */
141
+ findAll(selector) {
142
+ return findAll(tokens, selector).map(({ token, index }) => makeElement(token, tokens, index))
143
+ },
144
+
145
+ /**
146
+ * Get an attribute value from the first element matching selector.
147
+ * Returns null if the element or attribute is not found.
148
+ * @param {string} selector
149
+ * @param {string} name
150
+ * @returns {string | null}
151
+ */
152
+ attr(selector, name) {
153
+ return this.find(selector)?.attr(name) ?? null
154
+ },
155
+
156
+ /**
157
+ * Count elements matching selector.
158
+ * @param {string} selector
159
+ * @returns {number}
160
+ */
161
+ count(selector) {
162
+ return findAll(tokens, selector).length
163
+ },
164
+ }
165
+ }
166
+
167
+ // ---------------------------------------------------------------------------
168
+ // renderSync
169
+ // ---------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Render a spec's view synchronously.
173
+ *
174
+ * Calls the view function directly — no server fetcher resolution.
175
+ * Pass server state manually via options.server.
176
+ *
177
+ * Ideal for unit testing pure view functions where server data is mocked.
178
+ *
179
+ * @param {import('../../types/schema.js').PulseSpec} spec
180
+ * @param {{ state?: object, server?: object }} [options]
181
+ * @returns {RenderResult}
182
+ *
183
+ * @example
184
+ * const result = renderSync(counterSpec, { state: { count: 5 } })
185
+ * assert(result.has('span'))
186
+ * assert(result.get('span').text === '5')
187
+ */
188
+ export function renderSync(spec, options = {}) {
189
+ const { state: stateOverrides = {}, server = {} } = options
190
+ const state = { ...spec.state, ...stateOverrides }
191
+
192
+ const html = typeof spec.view === 'function'
193
+ ? spec.view(state, server)
194
+ : Object.values(spec.view).map(fn => fn(state, server)).join('')
195
+
196
+ return makeResult(String(html), state, server)
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // render
201
+ // ---------------------------------------------------------------------------
202
+
203
+ /**
204
+ * Render a spec's view asynchronously, running real server fetchers.
205
+ *
206
+ * If options.server is provided, those values are used directly and fetchers
207
+ * are skipped entirely — this is the fast mock path for unit tests.
208
+ *
209
+ * If options.server is omitted, spec.server fetchers are called with options.ctx
210
+ * (default: empty object). Use this for integration tests.
211
+ *
212
+ * @param {import('../../types/schema.js').PulseSpec} spec
213
+ * @param {{ state?: object, server?: object, ctx?: object }} [options]
214
+ * @returns {Promise<RenderResult>}
215
+ *
216
+ * @example
217
+ * // Mock server data — no real fetcher calls
218
+ * const result = await render(productSpec, {
219
+ * server: { product: { id: 1, name: 'Widget' } }
220
+ * })
221
+ * assert.equal(result.get('h1').text, 'Widget')
222
+ *
223
+ * // Real fetchers — integration test
224
+ * const result = await render(productSpec, { ctx: { params: { id: '1' } } })
225
+ */
226
+ export async function render(spec, options = {}) {
227
+ const { state: stateOverrides = {}, server: serverOverrides, ctx = {} } = options
228
+ const state = { ...spec.state, ...stateOverrides }
229
+
230
+ let server
231
+ if (serverOverrides !== undefined) {
232
+ // Caller provided mock server state — skip fetchers
233
+ server = serverOverrides
234
+ } else if (spec.server) {
235
+ // Run real fetchers in parallel
236
+ const entries = await Promise.all(
237
+ Object.entries(spec.server).map(async ([key, fn]) => [key, await fn(ctx)])
238
+ )
239
+ server = Object.fromEntries(entries)
240
+ } else {
241
+ server = {}
242
+ }
243
+
244
+ const html = typeof spec.view === 'function'
245
+ ? spec.view(state, server)
246
+ : Object.values(spec.view).map(fn => fn(state, server)).join('')
247
+
248
+ return makeResult(String(html), state, server)
249
+ }