@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,411 @@
1
+ /**
2
+ * Pulse — Build script
3
+ *
4
+ * Auto-discovers pages from src/pages/, generates a self-executing bootstrap
5
+ * module per page, bundles everything with a shared runtime chunk.
6
+ *
7
+ * Usage:
8
+ * node scripts/build.js [--root /path/to/project]
9
+ *
10
+ * Output:
11
+ * public/dist/<name>-<hash>.js — minified self-executing bundle per page
12
+ * public/dist/runtime-<hash>.js — shared runtime chunk
13
+ * public/dist/manifest.json — maps source hydrate paths → bundle paths
14
+ */
15
+
16
+ import * as esbuild from 'esbuild'
17
+ import fs from 'fs'
18
+ import path from 'path'
19
+ import { createHash } from 'crypto'
20
+ import { discoverPages } from '../src/cli/discover.js'
21
+ import { renderToString } from '../src/runtime/ssr.js'
22
+
23
+ // Project root — can be overridden via --root flag for CLI usage
24
+ const rootArg = process.argv.indexOf('--root')
25
+ const ROOT = rootArg !== -1
26
+ ? path.resolve(process.argv[rootArg + 1])
27
+ : path.resolve(import.meta.dirname, '..')
28
+
29
+ const OUT_DIR = path.join(ROOT, 'public', 'dist')
30
+ const TMP_DIR = path.join(ROOT, '.pulse-build')
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Discover pages
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const pages = discoverPages(ROOT)
37
+
38
+ if (pages.length === 0) {
39
+ console.error('No pages found in src/pages/. Nothing to build.')
40
+ process.exit(1)
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Clean output directories
45
+ // ---------------------------------------------------------------------------
46
+
47
+ for (const dir of [OUT_DIR, TMP_DIR]) {
48
+ if (fs.existsSync(dir)) fs.rmSync(dir, { recursive: true })
49
+ fs.mkdirSync(dir, { recursive: true })
50
+ }
51
+
52
+ console.log('⚡ Building Pulse client bundles...\n')
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // Generate bootstrap entry points
56
+ // ---------------------------------------------------------------------------
57
+
58
+ const RUNTIME_PATH = new URL('../src/runtime/index.js', import.meta.url).pathname
59
+ const NAVIGATE_PATH = new URL('../src/runtime/navigate.js', import.meta.url).pathname
60
+
61
+ const PAGES_DIR = path.join(ROOT, 'src', 'pages')
62
+
63
+ const bootstrapFiles = pages.map(({ filePath }) => {
64
+ // Use the path relative to src/pages/ so nested pages get unique names.
65
+ // e.g. src/pages/api/products.js → 'api--products' (not 'products')
66
+ const relToPages = path.relative(PAGES_DIR, filePath)
67
+ const name = relToPages.replace(/\.js$/, '').replace(/[\\/]/g, '--')
68
+ const bootstrapPath = path.join(TMP_DIR, `${name}.boot.js`)
69
+ const relSpec = path.relative(TMP_DIR, filePath)
70
+ const relRuntime = path.relative(TMP_DIR, RUNTIME_PATH)
71
+ const relNavigate = path.relative(TMP_DIR, NAVIGATE_PATH)
72
+
73
+ fs.writeFileSync(bootstrapPath, `\
74
+ import spec from '${relSpec}'
75
+ import { mount } from '${relRuntime}'
76
+ import { initNavigation } from '${relNavigate}'
77
+
78
+ const root = document.getElementById('pulse-root')
79
+ if (root && !root.dataset.pulseMounted) {
80
+ root.dataset.pulseMounted = '1'
81
+ mount(spec, root, window.__PULSE_SERVER__ || {}, { ssr: true })
82
+ initNavigation(root, mount)
83
+ }
84
+
85
+ export default spec
86
+ `)
87
+
88
+ return { filePath, bootstrapPath, name }
89
+ })
90
+
91
+ // ---------------------------------------------------------------------------
92
+ // Bundle
93
+ // ---------------------------------------------------------------------------
94
+
95
+ const result = await esbuild.build({
96
+ entryPoints: bootstrapFiles.map(b => b.bootstrapPath),
97
+ bundle: true,
98
+ format: 'esm',
99
+ platform: 'browser',
100
+ outdir: OUT_DIR,
101
+ entryNames: '[name]-[hash]',
102
+ chunkNames: 'runtime-[hash]',
103
+ splitting: true,
104
+ minify: true,
105
+ metafile: true,
106
+ sourcemap: false,
107
+ treeShaking: true,
108
+ define: {
109
+ 'process.env.NODE_ENV': '"production"'
110
+ }
111
+ })
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // Build manifest — maps source hydrate paths → bundle paths
115
+ // ---------------------------------------------------------------------------
116
+
117
+ const manifest = {}
118
+
119
+ for (const [outFile, meta] of Object.entries(result.metafile.outputs)) {
120
+ const bundlePath = '/' + path.relative(path.join(ROOT, 'public'), path.join(ROOT, outFile))
121
+
122
+ // Shared runtime chunk — esbuild generates this via splitting, no entryPoint
123
+ if (!meta.entryPoint && path.basename(outFile).startsWith('runtime-')) {
124
+ manifest['_runtime'] = bundlePath
125
+ continue
126
+ }
127
+
128
+ if (!meta.entryPoint) continue
129
+
130
+ const bootstrapName = path.basename(meta.entryPoint, '.boot.js')
131
+ const entry = bootstrapFiles.find(b => b.name === bootstrapName)
132
+ if (!entry) continue
133
+
134
+ // Hydrate key is the path the browser uses to import the spec
135
+ const sourceKey = '/' + path.relative(ROOT, entry.filePath)
136
+ manifest[sourceKey] = bundlePath
137
+ }
138
+
139
+ fs.writeFileSync(path.join(OUT_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2))
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Cleanup temp dir
143
+ // ---------------------------------------------------------------------------
144
+
145
+ fs.rmSync(TMP_DIR, { recursive: true })
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // CSS Purge — strip unused styles, write content-hashed CSS bundles
149
+ // ---------------------------------------------------------------------------
150
+
151
+ await purgeCssStep(pages, manifest, ROOT, OUT_DIR)
152
+
153
+ // ---------------------------------------------------------------------------
154
+ // Report
155
+ // ---------------------------------------------------------------------------
156
+
157
+ console.log('Bundles:\n')
158
+ for (const [src, bundle] of Object.entries(manifest)) {
159
+ if (bundle.startsWith('/dist/')) {
160
+ const filePath = path.join(ROOT, 'public', bundle)
161
+ if (fs.existsSync(filePath)) {
162
+ const size = fs.statSync(filePath).size
163
+ console.log(` ${src.padEnd(36)} → ${bundle} (${(size / 1024).toFixed(1)} kB)`)
164
+ }
165
+ }
166
+ }
167
+ console.log('\n✓ manifest written to public/dist/manifest.json\n')
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // CSS Purge implementation
171
+ // ---------------------------------------------------------------------------
172
+
173
+ async function purgeCssStep(pages, manifest, root, outDir) {
174
+ console.log('⚡ Purging CSS...\n')
175
+
176
+ const htmlContents = []
177
+ const jsContents = []
178
+ const cssFiles = new Set()
179
+
180
+ for (const { filePath } of pages) {
181
+ try {
182
+ const mod = await import(filePath)
183
+ const spec = mod.default
184
+ if (!spec || typeof spec !== 'object') continue
185
+
186
+ // Collect CSS files referenced by this page
187
+ if (Array.isArray(spec.meta?.styles)) {
188
+ for (const href of spec.meta.styles) cssFiles.add(href)
189
+ }
190
+
191
+ // SSR render to extract class names from the actual HTML output
192
+ try {
193
+ const { html } = await renderToString(spec, {})
194
+ htmlContents.push(html)
195
+ } catch {
196
+ // Server data might fail in build context — fall back to direct view call
197
+ try {
198
+ const html = spec.view(spec.state || {}, {})
199
+ if (typeof html === 'string') htmlContents.push(html)
200
+ } catch {}
201
+ }
202
+
203
+ // Also scan source JS for conditionally-applied class names
204
+ jsContents.push(fs.readFileSync(filePath, 'utf8'))
205
+ } catch {}
206
+ }
207
+
208
+ // Scan project components directory for conditional class names
209
+ const componentsDir = path.join(root, 'src', 'components')
210
+ if (fs.existsSync(componentsDir)) {
211
+ const componentFiles = fs.readdirSync(componentsDir).filter(f => f.endsWith('.js'))
212
+ for (const f of componentFiles) {
213
+ try { jsContents.push(fs.readFileSync(path.join(componentsDir, f), 'utf8')) } catch {}
214
+ }
215
+ }
216
+
217
+ if (cssFiles.size === 0) {
218
+ console.log(' No CSS files referenced — skipping.\n')
219
+ return
220
+ }
221
+
222
+ const usedClasses = extractUsedClasses(htmlContents, jsContents)
223
+
224
+ for (const cssHref of cssFiles) {
225
+ // CSS path is relative to public/ (e.g. '/pulse-ui.css' → public/pulse-ui.css)
226
+ const cssPath = path.join(root, 'public', cssHref.replace(/^\//, ''))
227
+ if (!fs.existsSync(cssPath)) continue
228
+
229
+ const original = fs.readFileSync(cssPath, 'utf8')
230
+ const purged = minifyCss(purgeCss(original, usedClasses))
231
+
232
+ const hash = createHash('sha256').update(purged).digest('hex').slice(0, 8)
233
+ const name = path.basename(cssHref, '.css')
234
+ const outName = `${name}-${hash}.css`
235
+ const outPath = path.join(outDir, outName)
236
+
237
+ fs.writeFileSync(outPath, purged)
238
+
239
+ const origKb = (Buffer.byteLength(original) / 1024).toFixed(1)
240
+ const purgKb = (Buffer.byteLength(purged) / 1024).toFixed(1)
241
+ const pct = Math.round((1 - Buffer.byteLength(purged) / Buffer.byteLength(original)) * 100)
242
+
243
+ console.log(` ${cssHref.padEnd(28)} → /dist/${outName} (${purgKb} kB, ${pct}% removed from ${origKb} kB)`)
244
+
245
+ manifest[cssHref] = `/dist/${outName}`
246
+ }
247
+
248
+ // Re-write manifest with CSS entries added
249
+ fs.writeFileSync(path.join(outDir, 'manifest.json'), JSON.stringify(manifest, null, 2))
250
+ console.log('\n✓ CSS entries added to manifest\n')
251
+ }
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // CSS class extractor
255
+ // ---------------------------------------------------------------------------
256
+
257
+ function extractUsedClasses(htmlContents, jsContents) {
258
+ const used = new Set()
259
+
260
+ // Extract from class="..." in both rendered HTML and JS template literals
261
+ for (const content of [...htmlContents, ...jsContents]) {
262
+ const re = /class(?:Name)?=["']([^"'\n]+)["']/g
263
+ let m
264
+ while ((m = re.exec(content)) !== null) {
265
+ for (const cls of m[1].trim().split(/\s+/)) {
266
+ if (cls) used.add(cls)
267
+ }
268
+ }
269
+ }
270
+
271
+ // Scan JS source for quoted strings that look like CSS class names (contain hyphens)
272
+ // Catches conditionally-applied classes like 'ui-btn--disabled' in ternaries
273
+ for (const js of jsContents) {
274
+ const re = /['"`]((?:[a-zA-Z][a-zA-Z0-9]*-[a-zA-Z0-9-]*)(?:\s+[a-zA-Z][a-zA-Z0-9-]*)*)['"`]/g
275
+ let m
276
+ while ((m = re.exec(js)) !== null) {
277
+ for (const cls of m[1].trim().split(/\s+/)) {
278
+ if (cls && cls.includes('-')) used.add(cls)
279
+ }
280
+ }
281
+ }
282
+
283
+ return used
284
+ }
285
+
286
+ // ---------------------------------------------------------------------------
287
+ // CSS purger — strips unused rules, preserves @media / :root / element rules
288
+ // ---------------------------------------------------------------------------
289
+
290
+ /**
291
+ * Parse CSS text into top-level blocks using a bracket-depth counter.
292
+ * Returns [{ type, selector?, prelude?, inner?, raw }]
293
+ */
294
+ function parseCssBlocks(css) {
295
+ const blocks = []
296
+ let i = 0
297
+ const len = css.length
298
+
299
+ while (i < len) {
300
+ // Skip whitespace
301
+ while (i < len && /\s/.test(css[i])) i++
302
+ if (i >= len) break
303
+
304
+ // Skip block comments
305
+ if (css[i] === '/' && css[i + 1] === '*') {
306
+ const end = css.indexOf('*/', i + 2)
307
+ i = end === -1 ? len : end + 2
308
+ continue
309
+ }
310
+
311
+ const start = i
312
+ let depth = 0
313
+
314
+ while (i < len) {
315
+ // Skip inline comments
316
+ if (css[i] === '/' && css[i + 1] === '*') {
317
+ const end = css.indexOf('*/', i + 2)
318
+ i = end === -1 ? len : end + 2
319
+ continue
320
+ }
321
+ if (css[i] === '{') { depth++; i++; continue }
322
+ if (css[i] === '}') { depth--; i++; if (depth === 0) break; continue }
323
+ if (css[i] === ';' && depth === 0) { i++; break }
324
+ i++
325
+ }
326
+
327
+ const raw = css.slice(start, i).trim()
328
+ if (!raw) continue
329
+
330
+ if (raw.startsWith('@')) {
331
+ const braceIdx = raw.indexOf('{')
332
+ if (braceIdx === -1) {
333
+ blocks.push({ type: 'at-simple', raw })
334
+ } else {
335
+ const prelude = raw.slice(0, braceIdx).trim()
336
+ const inner = raw.slice(braceIdx + 1, raw.lastIndexOf('}')).trim()
337
+ blocks.push({ type: 'at-block', prelude, inner, raw })
338
+ }
339
+ } else {
340
+ const braceIdx = raw.indexOf('{')
341
+ if (braceIdx !== -1) {
342
+ blocks.push({ type: 'rule', selector: raw.slice(0, braceIdx).trim(), raw })
343
+ }
344
+ }
345
+ }
346
+
347
+ return blocks
348
+ }
349
+
350
+ /**
351
+ * Return true if any selector in the rule should be kept.
352
+ * Keeps: element selectors, *, :root, and any rule where a used class appears.
353
+ */
354
+ function selectorUsed(selector, usedClasses) {
355
+ const selectors = selector.split(',').map(s => s.trim())
356
+
357
+ for (const sel of selectors) {
358
+ // Strip pseudo-classes/elements and attribute selectors to find the base
359
+ const base = sel
360
+ .replace(/::?[a-z-]+(\([^)]*\))?/gi, '')
361
+ .replace(/\[[^\]]*\]/g, '')
362
+ .trim()
363
+
364
+ // Keep element selectors, *, :root — no class tokens
365
+ if (!base.includes('.')) return true
366
+
367
+ // Keep if any class in the selector is in the used set
368
+ const classes = [...sel.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]*)/g)].map(m => m[1])
369
+ if (classes.some(cls => usedClasses.has(cls))) return true
370
+ }
371
+
372
+ return false
373
+ }
374
+
375
+ /**
376
+ * Purge unused CSS rules from a CSS string.
377
+ * Recursively processes @media and @supports blocks.
378
+ */
379
+ function minifyCss(css) {
380
+ return css
381
+ .replace(/\/\*[\s\S]*?\*\//g, '') // strip comments
382
+ .replace(/\s+/g, ' ') // collapse whitespace
383
+ .replace(/\s*([{}:;,>~+])\s*/g, '$1') // remove spaces around punctuation
384
+ .replace(/;}/g, '}') // remove trailing semicolons
385
+ .trim()
386
+ }
387
+
388
+ function purgeCss(cssText, usedClasses) {
389
+ const blocks = parseCssBlocks(cssText)
390
+ const kept = []
391
+
392
+ for (const block of blocks) {
393
+ if (block.type === 'at-simple') {
394
+ kept.push(block.raw)
395
+ } else if (block.type === 'at-block') {
396
+ const p = block.prelude.toLowerCase()
397
+ if (/^@(?:keyframes|font-face|charset)/.test(p)) {
398
+ kept.push(block.raw)
399
+ } else if (/^@(?:media|supports|layer)/.test(p)) {
400
+ const innerPurged = purgeCss(block.inner, usedClasses)
401
+ if (innerPurged.trim()) kept.push(`${block.prelude} {\n${innerPurged}\n}`)
402
+ } else {
403
+ kept.push(block.raw) // unknown at-blocks — keep to be safe
404
+ }
405
+ } else if (block.type === 'rule') {
406
+ if (selectorUsed(block.selector, usedClasses)) kept.push(block.raw)
407
+ }
408
+ }
409
+
410
+ return kept.join('\n\n')
411
+ }
@@ -0,0 +1,111 @@
1
+ ## Spec review checklist
2
+
3
+ Before finishing any spec, verify every point below. Fix anything that fails.
4
+
5
+ ### Critical
6
+
7
+ - **`hydrate` is set on every interactive page.** Without it, `data-event` / `data-action` bindings do nothing, `persist` never runs, and client-side navigation cannot re-mount the page. Every spec with `mutations`, `actions`, or `persist` must include:
8
+ ```js
9
+ hydrate: '/src/pages/my-page.js', // browser-importable path to this file
10
+ ```
11
+ Omit `hydrate` only for purely server-rendered pages with zero client interactivity.
12
+
13
+ ### Components first
14
+
15
+ - **Before writing any HTML by hand, check `src/ui/index.js`.** There are 50+ components. Use `button`, `card`, `alert`, `input`, `spinner`, `badge`, `modal`, `nav`, `pagination`, `table`, etc. before writing equivalent HTML from scratch.
16
+
17
+ ### Reuse (DRY)
18
+
19
+ - **Extract a view helper when the same HTML pattern appears 3 or more times in a single spec.** A plain JS function returning an HTML string is sufficient — no framework needed:
20
+ ```js
21
+ const card = ({ title, body }) => `<div class="card"><h3>${title}</h3><p>${body}</p></div>`
22
+ ```
23
+ - **Create a shared component in `src/ui/` when the same pattern is needed across 2 or more different specs.** Follow the existing pattern: a named export that returns an HTML string.
24
+ - **Do not abstract a pattern that appears only once.** Duplication is cheaper than the wrong abstraction. Wait until the third use before extracting.
25
+
26
+ ### Correctness
27
+
28
+ - Mutations return plain partial-state objects and have no side effects (no fetch, no DOM access).
29
+ - `persist` contains only serialisable state that should survive a page reload — not ephemeral UI state like a loading flag or temporary selection.
30
+ - `e.target` assumptions in mutations are safe if the element has child nodes — use `e.target.closest('[data-index]')` rather than assuming `e.target` is the element with the attribute.
31
+ - State shape is consistent — avoid a single field that is sometimes `null`, sometimes a string, sometimes a boolean. Use a dedicated `status` field instead.
32
+ - **Never use `state.modalOpen` or conditional modal rendering.** This destroys the `<dialog>` on every render, breaking focus, animation, and native ESC handling. Instead, always render the `<dialog>` in the DOM and use `data-dialog-open="id"` to open it — the runtime handles this without any spec state:
33
+ ```html
34
+ <!-- always in the view, never conditional -->
35
+ ${modal({ id: 'confirm', title: 'Confirm', content: '...' })}
36
+ <!-- anywhere on the page — opens the dialog, no mutation needed -->
37
+ ${modalTrigger({ target: 'confirm', label: 'Open' })}
38
+ <!-- or inline: -->
39
+ <button data-dialog-open="confirm">Open</button>
40
+ ```
41
+ Close is handled natively by `<form method="dialog">` (inside the modal), ESC key, backdrop click, or `data-dialog-close` on any element.
42
+
43
+ ### Defensive data handling
44
+
45
+ - **Always check `res.ok` before parsing fetch responses.** Never call `res.json()` on a response that may have failed. Never use `await res.text()` as an error message — on a 404 or 500, this returns raw HTML which surfaces directly in toasts and error alerts. The correct pattern:
46
+ ```js
47
+ const res = await fetch('/api/...')
48
+ if (!res.ok) {
49
+ let message = `Request failed: ${res.status}`
50
+ try { const j = await res.json(); message = j.message || j.error || message } catch {}
51
+ throw new Error(message)
52
+ }
53
+ return await res.json()
54
+ ```
55
+ - **Never assume the shape of data from external APIs or server fetchers.** Use optional chaining (`?.`) and nullish coalescing (`??`) at every access point. If a server fetcher can return `null`, the view must handle it — `server.user?.name ?? 'Guest'` not `server.user.name`.
56
+ - **Validate FormData fields before use.** `formData.get('email')` returns `null` if the field is missing. Check for null/empty before passing to an API or database.
57
+ - **Do not trust URL params.** `ctx.params.id` is a raw string from the URL. Validate it before use — check it exists, is the right type, and refers to a real resource. Return a 404 or redirect if it doesn't.
58
+
59
+ ### Security
60
+
61
+ - Any value from user input (URL params, form fields, external APIs) interpolated into view HTML must be escaped.
62
+
63
+ ### Tests
64
+
65
+ - Pure logic functions extracted from a spec (e.g. `checkResult`, `validate`, `formatPrice`) must have unit tests in a corresponding `.test.js` file.
66
+ - **When fixing a bug, write a failing test first.** The test must reproduce the bug before the fix is applied, then pass after. This pins the behaviour so the bug cannot silently return. A fix without a regression test is incomplete.
67
+ - **Use `renderSync` / `render` from `@invisibleloop/pulse/testing` to test view HTML output.** Do not test views with raw `html.includes()` — use the query helpers instead:
68
+ ```js
69
+ import { renderSync, render } from '@invisibleloop/pulse/testing'
70
+
71
+ // Sync — call view directly with mock state/server
72
+ const result = renderSync(mySpec, { state: { count: 5 }, server: { items: [] } })
73
+ assert(result.has('button'))
74
+ assert.equal(result.get('#count').text, '5')
75
+ assert.equal(result.count('li'), 0)
76
+
77
+ // Async — run real server fetchers (integration), or pass server to skip them
78
+ const result = await render(mySpec, { server: { product: mockProduct } })
79
+ assert.equal(result.get('h1').text, mockProduct.name)
80
+ assert.equal(result.attr('img', 'src'), mockProduct.image)
81
+ ```
82
+ Supported selectors: `tag`, `.class`, `#id`, `[attr]`, `[attr="value"]`, and combinations (`button.primary[disabled]`).
83
+
84
+ ### View error handling
85
+
86
+ - **Define `onViewError` on any page where the view could throw due to bad or missing data.** Without it, a runtime view error returns a 500 on the server and shows a generic inline message on the client. With it, the server returns 200 with your fallback HTML instead of a 500, and the client renders your fallback:
87
+ ```js
88
+ onViewError: (err, state, serverState) => `
89
+ <div class="u-p-4 u-text-center">
90
+ <p>Something went wrong. <a href="">Reload</a></p>
91
+ </div>
92
+ `
93
+ ```
94
+ Use this on pages that render data from external APIs or user-supplied content — any path where the view can encounter `null`, `undefined`, or unexpected shapes that would cause a crash. It is not required on simple pages with predictable data.
95
+
96
+ ### Store updates from actions
97
+
98
+ - **Use `_storeUpdate` to push changes to the global store from an action.** Return it from `onSuccess` alongside the local state update — it is stripped from page state and forwarded to the store. All mounted pages that subscribe to the affected keys re-render immediately:
99
+ ```js
100
+ onSuccess: (state, theme) => ({
101
+ saved: true,
102
+ _storeUpdate: { settings: { theme } }, // ← merged into store state
103
+ }),
104
+ ```
105
+ `_storeUpdate` only merges into the store — it does not appear in the page's own state. The rest of the return is merged into local state as normal. Use this instead of a full-page reload when a user action changes shared data (theme, cart count, user profile, etc.).
106
+
107
+ ### Accessibility
108
+
109
+ - Interactive elements without visible text have an `aria-label`.
110
+ - Disabled state is reflected with the `disabled` attribute, not just CSS.
111
+ - The page has a `<main id="main-content">` landmark.
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * coverage-check.js
4
+ *
5
+ * Called by the Stop hook in .claude/settings.json to enforce test coverage.
6
+ * Runs `npm run test:coverage` and blocks the agent if any src/pages/ file
7
+ * has uncovered lines — except async action run() and server fetcher functions
8
+ * which cannot be tested without mocking real APIs.
9
+ *
10
+ * Outputs a Claude Code hook JSON decision to stdout.
11
+ */
12
+
13
+ import { execSync } from 'child_process'
14
+ import fs from 'fs'
15
+
16
+ // No test files → nothing to check
17
+ const hasTests = fs.existsSync('src/pages') && (function scan(d) {
18
+ for (const f of fs.readdirSync(d)) {
19
+ const p = `${d}/${f}`
20
+ if (fs.statSync(p).isDirectory()) { if (scan(p)) return true }
21
+ else if (f.endsWith('.test.js')) return true
22
+ }
23
+ return false
24
+ })('src/pages')
25
+
26
+ if (!hasTests) process.exit(0)
27
+
28
+ let out = ''
29
+ try {
30
+ out = execSync('npm run test:coverage 2>&1', { encoding: 'utf8', timeout: 60000 })
31
+ } catch (e) {
32
+ out = e.stdout || e.message
33
+ }
34
+
35
+ // Parse the coverage report — look for lines with uncovered line numbers
36
+ const lines = out.split('\n')
37
+ let inReport = false
38
+ const gaps = []
39
+
40
+ for (const line of lines) {
41
+ if (line.includes('start of coverage report')) { inReport = true; continue }
42
+ if (!inReport) continue
43
+
44
+ // Match: # filename.js | line% | branch% | func% | 30-36 50
45
+ const m = line.match(/^[#ℹ]\s+([\w./[\]-]+\.js)\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*[\d.]+\s*\|\s*([^|\n]+)$/)
46
+ if (m) {
47
+ const uncov = m[2].trim()
48
+ if (uncov && uncov !== 'uncovered lines') {
49
+ gaps.push(` ${m[1].trim()} — uncovered lines: ${uncov}`)
50
+ }
51
+ }
52
+ }
53
+
54
+ if (gaps.length) {
55
+ process.stdout.write(JSON.stringify({
56
+ decision: 'block',
57
+ reason: [
58
+ 'COVERAGE GAPS detected. Add tests for these uncovered lines before finishing:',
59
+ ...gaps,
60
+ '',
61
+ 'Run `npm run test:coverage` to see the full report.',
62
+ 'Exempt from this rule: async action run() functions and server fetchers that call real APIs.',
63
+ 'Everything else — view branches, mutations, onViewError, pure helper functions — must be covered.',
64
+ ].join('\n'),
65
+ }))
66
+ }