@scaffold-ui-kit/preview 0.0.0-stage → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (350) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1868 -2
  3. package/application/host/bootstrap.ts +43 -0
  4. package/application/host/main.ts +31 -0
  5. package/application/host/shell-host.ts +47 -0
  6. package/application/index.html +12 -0
  7. package/application/lib/base-url.ts +15 -0
  8. package/application/lib/no-code-block.ts +7 -0
  9. package/application/lib/no-setup.ts +3 -0
  10. package/application/lib/no-views.ts +6 -0
  11. package/application/preview/code/code.html +12 -0
  12. package/application/preview/code/main.ts +89 -0
  13. package/application/preview/globals.ts +254 -0
  14. package/application/preview/html/main.ts +93 -0
  15. package/application/preview/html/preview.html +12 -0
  16. package/application/preview/react/main.tsx +146 -0
  17. package/application/preview/react/preview.html +12 -0
  18. package/application/preview/svelte/Preview.svelte +104 -0
  19. package/application/preview/svelte/main.ts +13 -0
  20. package/application/preview/svelte/preview.html +12 -0
  21. package/application/preview/svelte/story-props.ts +172 -0
  22. package/application/preview/view/main.ts +124 -0
  23. package/application/preview/view/view.html +12 -0
  24. package/application/preview/vue/main.ts +145 -0
  25. package/application/preview/vue/preview.html +12 -0
  26. package/application/shells/charcell/components/Application/index.css +37 -0
  27. package/application/shells/charcell/components/Application/index.tsx +65 -0
  28. package/application/shells/charcell/components/Bar/index.css +68 -0
  29. package/application/shells/charcell/components/Bar/index.tsx +60 -0
  30. package/application/shells/charcell/components/CodePane/index.css +60 -0
  31. package/application/shells/charcell/components/CodePane/index.tsx +113 -0
  32. package/application/shells/charcell/components/ComponentStage/index.css +26 -0
  33. package/application/shells/charcell/components/ComponentStage/index.tsx +110 -0
  34. package/application/shells/charcell/components/Control/index.css +20 -0
  35. package/application/shells/charcell/components/Control/index.tsx +118 -0
  36. package/application/shells/charcell/components/CopyLink/index.tsx +35 -0
  37. package/application/shells/charcell/components/Description/index.css +6 -0
  38. package/application/shells/charcell/components/Description/index.tsx +24 -0
  39. package/application/shells/charcell/components/EditorAddDialog/index.css +9 -0
  40. package/application/shells/charcell/components/EditorAddDialog/index.tsx +158 -0
  41. package/application/shells/charcell/components/EditorPanel/index.css +48 -0
  42. package/application/shells/charcell/components/EditorPanel/index.tsx +496 -0
  43. package/application/shells/charcell/components/EventsRow/index.css +42 -0
  44. package/application/shells/charcell/components/EventsRow/index.tsx +68 -0
  45. package/application/shells/charcell/components/HeadingRow/index.css +11 -0
  46. package/application/shells/charcell/components/HeadingRow/index.tsx +16 -0
  47. package/application/shells/charcell/components/Hint/index.css +16 -0
  48. package/application/shells/charcell/components/Hint/index.tsx +81 -0
  49. package/application/shells/charcell/components/HtmlWarnings/index.tsx +45 -0
  50. package/application/shells/charcell/components/KeyHints/index.css +27 -0
  51. package/application/shells/charcell/components/KeyHints/index.tsx +106 -0
  52. package/application/shells/charcell/components/KitCodeBlock/index.css +55 -0
  53. package/application/shells/charcell/components/KitCodeBlock/index.tsx +18 -0
  54. package/application/shells/charcell/components/Markdown/index.tsx +16 -0
  55. package/application/shells/charcell/components/Navigation/index.css +13 -0
  56. package/application/shells/charcell/components/Navigation/index.tsx +98 -0
  57. package/application/shells/charcell/components/NavigationSplit/index.css +16 -0
  58. package/application/shells/charcell/components/NavigationSplit/index.tsx +82 -0
  59. package/application/shells/charcell/components/OptionControls/index.tsx +93 -0
  60. package/application/shells/charcell/components/PaneStatus/index.css +7 -0
  61. package/application/shells/charcell/components/PaneStatus/index.tsx +47 -0
  62. package/application/shells/charcell/components/PaneWidth/index.tsx +45 -0
  63. package/application/shells/charcell/components/PreviewGrid/index.css +39 -0
  64. package/application/shells/charcell/components/PreviewGrid/index.tsx +344 -0
  65. package/application/shells/charcell/components/PreviewPane/index.css +127 -0
  66. package/application/shells/charcell/components/PreviewPane/index.tsx +182 -0
  67. package/application/shells/charcell/components/PromptBar/index.tsx +133 -0
  68. package/application/shells/charcell/components/ScenarioRow/index.css +36 -0
  69. package/application/shells/charcell/components/ScenarioRow/index.tsx +71 -0
  70. package/application/shells/charcell/components/Select/index.tsx +48 -0
  71. package/application/shells/charcell/components/SlotOutlet/index.css +7 -0
  72. package/application/shells/charcell/components/SlotOutlet/index.tsx +25 -0
  73. package/application/shells/charcell/components/StageBar/index.css +7 -0
  74. package/application/shells/charcell/components/StageBar/index.tsx +109 -0
  75. package/application/shells/charcell/components/StageSplit/index.css +11 -0
  76. package/application/shells/charcell/components/StageSplit/index.tsx +99 -0
  77. package/application/shells/charcell/components/StatusBar/index.tsx +30 -0
  78. package/application/shells/charcell/components/ViewControls/index.css +15 -0
  79. package/application/shells/charcell/components/ViewControls/index.tsx +58 -0
  80. package/application/shells/charcell/components/ViewHeading/index.css +13 -0
  81. package/application/shells/charcell/components/ViewHeading/index.tsx +8 -0
  82. package/application/shells/charcell/components/ViewStage/index.css +9 -0
  83. package/application/shells/charcell/components/ViewStage/index.tsx +25 -0
  84. package/application/shells/charcell/components/WrapToggle/index.tsx +27 -0
  85. package/application/shells/charcell/configuration.ts +45 -0
  86. package/application/shells/charcell/controller-context.ts +27 -0
  87. package/application/shells/charcell/fit-labels.ts +42 -0
  88. package/application/shells/charcell/html-warnings.ts +18 -0
  89. package/application/shells/charcell/index.tsx +82 -0
  90. package/application/shells/charcell/page-loading.html +57 -0
  91. package/application/shells/charcell/placement.ts +24 -0
  92. package/application/shells/charcell/shell-options.ts +23 -0
  93. package/application/shells/charcell/shell.css +37 -0
  94. package/application/shells/charcell/shell.tsx +25 -0
  95. package/application/shells/charcell/split-reset.ts +47 -0
  96. package/application/shells/charcell/stacked-rows.ts +117 -0
  97. package/application/shells/charcell/stage-toggles.ts +93 -0
  98. package/application/types/manifest.d.ts +6 -0
  99. package/application/types/preview-configuration.d.ts +10 -0
  100. package/application/types/preview-shell.d.ts +7 -0
  101. package/application/types/react-registry.d.ts +15 -0
  102. package/application/types/vue-registry.d.ts +6 -0
  103. package/dist/build.d.ts +24 -0
  104. package/dist/build.js +34 -0
  105. package/dist/check.d.ts +48 -0
  106. package/dist/check.js +781 -0
  107. package/dist/cli.d.ts +2 -0
  108. package/dist/cli.js +88 -0
  109. package/dist/context.d.ts +179 -0
  110. package/dist/context.js +783 -0
  111. package/dist/controller/catalog.d.ts +339 -0
  112. package/dist/controller/catalog.js +367 -0
  113. package/dist/controller/create-preview-controller.d.ts +532 -0
  114. package/dist/controller/create-preview-controller.js +1707 -0
  115. package/dist/controller/editor-tree.d.ts +161 -0
  116. package/dist/controller/editor-tree.js +373 -0
  117. package/dist/controller/html-source.d.ts +62 -0
  118. package/dist/controller/html-source.js +61 -0
  119. package/dist/controller/index.d.ts +9 -0
  120. package/dist/controller/index.js +12 -0
  121. package/dist/controller/persistence.d.ts +191 -0
  122. package/dist/controller/persistence.js +315 -0
  123. package/dist/controller/regeneration.d.ts +30 -0
  124. package/dist/controller/regeneration.js +27 -0
  125. package/dist/controller/source.d.ts +80 -0
  126. package/dist/controller/source.js +236 -0
  127. package/dist/controller/store.d.ts +27 -0
  128. package/dist/controller/store.js +40 -0
  129. package/dist/declarations/application/shells/charcell/components/Application/index.d.ts +2 -0
  130. package/dist/declarations/application/shells/charcell/components/Bar/index.d.ts +11 -0
  131. package/dist/declarations/application/shells/charcell/components/CodePane/index.d.ts +5 -0
  132. package/dist/declarations/application/shells/charcell/components/ComponentStage/index.d.ts +2 -0
  133. package/dist/declarations/application/shells/charcell/components/Control/index.d.ts +9 -0
  134. package/dist/declarations/application/shells/charcell/components/CopyLink/index.d.ts +1 -0
  135. package/dist/declarations/application/shells/charcell/components/Description/index.d.ts +5 -0
  136. package/dist/declarations/application/shells/charcell/components/EditorAddDialog/index.d.ts +6 -0
  137. package/dist/declarations/application/shells/charcell/components/EditorPanel/index.d.ts +2 -0
  138. package/dist/declarations/application/shells/charcell/components/EventsRow/index.d.ts +4 -0
  139. package/dist/declarations/application/shells/charcell/components/HeadingRow/index.d.ts +6 -0
  140. package/dist/declarations/application/shells/charcell/components/Hint/index.d.ts +11 -0
  141. package/dist/declarations/application/shells/charcell/components/HtmlWarnings/index.d.ts +5 -0
  142. package/dist/declarations/application/shells/charcell/components/KeyHints/index.d.ts +2 -0
  143. package/dist/declarations/application/shells/charcell/components/KitCodeBlock/index.d.ts +5 -0
  144. package/dist/declarations/application/shells/charcell/components/Markdown/index.d.ts +3 -0
  145. package/dist/declarations/application/shells/charcell/components/Navigation/index.d.ts +8 -0
  146. package/dist/declarations/application/shells/charcell/components/NavigationSplit/index.d.ts +7 -0
  147. package/dist/declarations/application/shells/charcell/components/OptionControls/index.d.ts +7 -0
  148. package/dist/declarations/application/shells/charcell/components/PaneStatus/index.d.ts +2 -0
  149. package/dist/declarations/application/shells/charcell/components/PaneWidth/index.d.ts +4 -0
  150. package/dist/declarations/application/shells/charcell/components/PreviewGrid/index.d.ts +7 -0
  151. package/dist/declarations/application/shells/charcell/components/PreviewPane/index.d.ts +9 -0
  152. package/dist/declarations/application/shells/charcell/components/PromptBar/index.d.ts +1 -0
  153. package/dist/declarations/application/shells/charcell/components/ScenarioRow/index.d.ts +2 -0
  154. package/dist/declarations/application/shells/charcell/components/Select/index.d.ts +16 -0
  155. package/dist/declarations/application/shells/charcell/components/SlotOutlet/index.d.ts +5 -0
  156. package/dist/declarations/application/shells/charcell/components/StageBar/index.d.ts +2 -0
  157. package/dist/declarations/application/shells/charcell/components/StageSplit/index.d.ts +17 -0
  158. package/dist/declarations/application/shells/charcell/components/StatusBar/index.d.ts +1 -0
  159. package/dist/declarations/application/shells/charcell/components/ViewControls/index.d.ts +9 -0
  160. package/dist/declarations/application/shells/charcell/components/ViewHeading/index.d.ts +4 -0
  161. package/dist/declarations/application/shells/charcell/components/ViewStage/index.d.ts +4 -0
  162. package/dist/declarations/application/shells/charcell/components/WrapToggle/index.d.ts +1 -0
  163. package/dist/declarations/application/shells/charcell/configuration.d.ts +31 -0
  164. package/dist/declarations/application/shells/charcell/controller-context.d.ts +9 -0
  165. package/dist/declarations/application/shells/charcell/fit-labels.d.ts +18 -0
  166. package/dist/declarations/application/shells/charcell/html-warnings.d.ts +5 -0
  167. package/dist/declarations/application/shells/charcell/index.d.ts +39 -0
  168. package/dist/declarations/application/shells/charcell/placement.d.ts +7 -0
  169. package/dist/declarations/application/shells/charcell/shell-options.d.ts +15 -0
  170. package/dist/declarations/application/shells/charcell/shell.d.ts +9 -0
  171. package/dist/declarations/application/shells/charcell/split-reset.d.ts +30 -0
  172. package/dist/declarations/application/shells/charcell/stacked-rows.d.ts +58 -0
  173. package/dist/declarations/application/shells/charcell/stage-toggles.d.ts +28 -0
  174. package/dist/declarations/src/controller/catalog.d.ts +339 -0
  175. package/dist/declarations/src/controller/create-preview-controller.d.ts +532 -0
  176. package/dist/declarations/src/controller/editor-tree.d.ts +161 -0
  177. package/dist/declarations/src/controller/html-source.d.ts +62 -0
  178. package/dist/declarations/src/controller/index.d.ts +9 -0
  179. package/dist/declarations/src/controller/persistence.d.ts +191 -0
  180. package/dist/declarations/src/controller/regeneration.d.ts +30 -0
  181. package/dist/declarations/src/controller/source.d.ts +80 -0
  182. package/dist/declarations/src/controller/store.d.ts +27 -0
  183. package/dist/declarations/src/emit/common.d.ts +103 -0
  184. package/dist/declarations/src/emit/manifest.d.ts +138 -0
  185. package/dist/declarations/src/emit/source.d.ts +87 -0
  186. package/dist/declarations/src/lib/jsx-roots.d.ts +22 -0
  187. package/dist/declarations/src/lib/module-of-source.d.ts +18 -0
  188. package/dist/declarations/src/lib/naming.d.ts +29 -0
  189. package/dist/declarations/src/lib/react-spelling.d.ts +68 -0
  190. package/dist/declarations/src/lib/scenario-story.d.ts +41 -0
  191. package/dist/declarations/src/lib/slot-fill.d.ts +15 -0
  192. package/dist/declarations/src/lib/types.d.ts +741 -0
  193. package/dist/declarations/src/options.d.ts +224 -0
  194. package/dist/declarations/src/protocol.d.ts +541 -0
  195. package/dist/declarations/src/react-view.d.ts +22 -0
  196. package/dist/declarations/src/react.d.ts +17 -0
  197. package/dist/declarations/src/shell.d.ts +99 -0
  198. package/dist/declarations/src/views.d.ts +152 -0
  199. package/dist/development-server.d.ts +121 -0
  200. package/dist/development-server.js +331 -0
  201. package/dist/emit/common.d.ts +103 -0
  202. package/dist/emit/common.js +170 -0
  203. package/dist/emit/html-source.d.ts +46 -0
  204. package/dist/emit/html-source.js +322 -0
  205. package/dist/emit/manifest.d.ts +138 -0
  206. package/dist/emit/manifest.js +74 -0
  207. package/dist/emit/react.d.ts +16 -0
  208. package/dist/emit/react.js +210 -0
  209. package/dist/emit/scratch.d.ts +40 -0
  210. package/dist/emit/scratch.js +116 -0
  211. package/dist/emit/source.d.ts +87 -0
  212. package/dist/emit/source.js +789 -0
  213. package/dist/emit/svelte.d.ts +9 -0
  214. package/dist/emit/svelte.js +177 -0
  215. package/dist/emit/vue.d.ts +2 -0
  216. package/dist/emit/vue.js +152 -0
  217. package/dist/enumerate.d.ts +2 -0
  218. package/dist/enumerate.js +162 -0
  219. package/dist/generate.d.ts +43 -0
  220. package/dist/generate.js +302 -0
  221. package/dist/html-stylesheet.d.ts +43 -0
  222. package/dist/html-stylesheet.js +145 -0
  223. package/dist/index.d.ts +18 -0
  224. package/dist/index.js +21 -0
  225. package/dist/landing.d.ts +16 -0
  226. package/dist/landing.js +45 -0
  227. package/dist/lib/exported-types.d.ts +42 -0
  228. package/dist/lib/exported-types.js +86 -0
  229. package/dist/lib/html-attributes.d.ts +2 -0
  230. package/dist/lib/html-attributes.js +111 -0
  231. package/dist/lib/html-warnings.d.ts +19 -0
  232. package/dist/lib/html-warnings.js +79 -0
  233. package/dist/lib/jsx-roots.d.ts +22 -0
  234. package/dist/lib/jsx-roots.js +243 -0
  235. package/dist/lib/module-of-source.d.ts +18 -0
  236. package/dist/lib/module-of-source.js +24 -0
  237. package/dist/lib/naming.d.ts +29 -0
  238. package/dist/lib/naming.js +56 -0
  239. package/dist/lib/parse-type.d.ts +32 -0
  240. package/dist/lib/parse-type.js +103 -0
  241. package/dist/lib/react-spelling.d.ts +68 -0
  242. package/dist/lib/react-spelling.js +197 -0
  243. package/dist/lib/scenario-story.d.ts +41 -0
  244. package/dist/lib/scenario-story.js +84 -0
  245. package/dist/lib/slot-fill.d.ts +15 -0
  246. package/dist/lib/slot-fill.js +92 -0
  247. package/dist/lib/template-files.d.ts +14 -0
  248. package/dist/lib/template-files.js +25 -0
  249. package/dist/lib/types.d.ts +741 -0
  250. package/dist/lib/types.js +13 -0
  251. package/dist/lib/void-elements.d.ts +7 -0
  252. package/dist/lib/void-elements.js +13 -0
  253. package/dist/load.d.ts +37 -0
  254. package/dist/load.js +82 -0
  255. package/dist/model.d.ts +44 -0
  256. package/dist/model.js +464 -0
  257. package/dist/options.d.ts +224 -0
  258. package/dist/options.js +223 -0
  259. package/dist/prop-types.d.ts +26 -0
  260. package/dist/prop-types.js +102 -0
  261. package/dist/props-from.d.ts +84 -0
  262. package/dist/props-from.js +510 -0
  263. package/dist/protocol.d.ts +541 -0
  264. package/dist/protocol.js +819 -0
  265. package/dist/react-view.d.ts +22 -0
  266. package/dist/react-view.js +36 -0
  267. package/dist/react.d.ts +17 -0
  268. package/dist/react.js +23 -0
  269. package/dist/sass-importers.d.ts +26 -0
  270. package/dist/sass-importers.js +32 -0
  271. package/dist/shell-acceptance.d.ts +21 -0
  272. package/dist/shell-acceptance.js +37 -0
  273. package/dist/shell-page.d.ts +36 -0
  274. package/dist/shell-page.js +90 -0
  275. package/dist/shell-peers.d.ts +38 -0
  276. package/dist/shell-peers.js +61 -0
  277. package/dist/shell.d.ts +99 -0
  278. package/dist/shell.js +52 -0
  279. package/dist/shells/charcell/page.d.ts +8 -0
  280. package/dist/shells/charcell/page.js +69 -0
  281. package/dist/story-source.d.ts +113 -0
  282. package/dist/story-source.js +271 -0
  283. package/dist/story.d.ts +5 -0
  284. package/dist/story.js +28 -0
  285. package/dist/testing.d.ts +218 -0
  286. package/dist/testing.js +284 -0
  287. package/dist/views.d.ts +152 -0
  288. package/dist/views.js +7 -0
  289. package/dist/vite-configuration.d.ts +114 -0
  290. package/dist/vite-configuration.js +578 -0
  291. package/package.json +123 -3
  292. package/src/build.ts +51 -0
  293. package/src/check.ts +1062 -0
  294. package/src/cli.ts +123 -0
  295. package/src/context.ts +1168 -0
  296. package/src/controller/catalog.ts +719 -0
  297. package/src/controller/create-preview-controller.ts +2683 -0
  298. package/src/controller/editor-tree.ts +530 -0
  299. package/src/controller/html-source.ts +114 -0
  300. package/src/controller/index.ts +105 -0
  301. package/src/controller/persistence.ts +403 -0
  302. package/src/controller/regeneration.ts +55 -0
  303. package/src/controller/source.ts +333 -0
  304. package/src/controller/store.ts +55 -0
  305. package/src/development-server.ts +442 -0
  306. package/src/emit/common.ts +201 -0
  307. package/src/emit/html-source.ts +402 -0
  308. package/src/emit/manifest.ts +241 -0
  309. package/src/emit/react.ts +263 -0
  310. package/src/emit/scratch.ts +175 -0
  311. package/src/emit/source.ts +1094 -0
  312. package/src/emit/svelte.ts +230 -0
  313. package/src/emit/vue.ts +194 -0
  314. package/src/enumerate.ts +220 -0
  315. package/src/generate.ts +443 -0
  316. package/src/html-stylesheet.ts +173 -0
  317. package/src/index.ts +60 -0
  318. package/src/landing.ts +58 -0
  319. package/src/lib/exported-types.ts +114 -0
  320. package/src/lib/html-attributes.ts +113 -0
  321. package/src/lib/html-warnings.ts +81 -0
  322. package/src/lib/jsx-roots.ts +238 -0
  323. package/src/lib/module-of-source.ts +26 -0
  324. package/src/lib/naming.ts +65 -0
  325. package/src/lib/parse-type.ts +143 -0
  326. package/src/lib/react-spelling.ts +235 -0
  327. package/src/lib/scenario-story.ts +97 -0
  328. package/src/lib/slot-fill.ts +102 -0
  329. package/src/lib/template-files.ts +31 -0
  330. package/src/lib/types.ts +778 -0
  331. package/src/lib/void-elements.ts +15 -0
  332. package/src/load.ts +144 -0
  333. package/src/model.ts +568 -0
  334. package/src/options.ts +435 -0
  335. package/src/prop-types.ts +133 -0
  336. package/src/props-from.ts +678 -0
  337. package/src/protocol.ts +1135 -0
  338. package/src/react-view.ts +45 -0
  339. package/src/react.ts +34 -0
  340. package/src/sass-importers.ts +45 -0
  341. package/src/shell-acceptance.ts +45 -0
  342. package/src/shell-page.ts +133 -0
  343. package/src/shell-peers.ts +70 -0
  344. package/src/shell.ts +194 -0
  345. package/src/shells/charcell/page.ts +82 -0
  346. package/src/story-source.ts +391 -0
  347. package/src/story.ts +42 -0
  348. package/src/testing.ts +497 -0
  349. package/src/views.ts +183 -0
  350. package/src/vite-configuration.ts +675 -0
@@ -0,0 +1,2683 @@
1
+ // The preview controller: the behavior of the preview with no UI of its own.
2
+ // It holds what is selected, the scenario, the option values, which target
3
+ // panes are shown (html's among them, where the kit builds html), whether each
4
+ // has loaded and how large its content is, the
5
+ // scenario editor's tree and the regeneration of the scratch story, and it owns the iframes: a shell
6
+ // hands it elements to mount them in and never speaks to them itself, the
7
+ // selection's panes and detached panes of any story alike. It keeps the
8
+ // selection, the scenario, the root props, the visible targets, the story
9
+ // panes' width and the option values in the host page's query parameters once
10
+ // a person acts, and
11
+ // the global option values in local storage when the kit asks.
12
+ // A shell's own options ride along, namespaced under `shell.`, and never
13
+ // reach the iframes. Every input is passed in, so it runs the same in a
14
+ // browser and in a test.
15
+
16
+ import {
17
+ acceptsOptionValue,
18
+ optionDefaults,
19
+ shellOptionId,
20
+ type OptionValue,
21
+ type PreviewOption,
22
+ type PreviewShellOption,
23
+ } from '../options.js';
24
+ import {
25
+ catalogMessage,
26
+ codeFrameUrl,
27
+ codeMessage,
28
+ componentFrameUrl,
29
+ globalsMessage,
30
+ isPreviewMessage,
31
+ markupMessage,
32
+ PREVIEW_MESSAGE,
33
+ PREVIEW_TARGETS,
34
+ propsMessage,
35
+ readContentSize,
36
+ setStoryMessage,
37
+ standaloneFrameUrl,
38
+ viewFrameUrl,
39
+ type PreviewCatalogPayload,
40
+ type PreviewContentSize,
41
+ type PreviewScriptedTarget,
42
+ type PreviewTarget,
43
+ } from '../protocol.js';
44
+ import {
45
+ defaultScenarioId,
46
+ nodeAddSlots,
47
+ nodeSlots,
48
+ storyFrameKey,
49
+ type PreviewCatalog,
50
+ type PreviewComponent,
51
+ type PreviewScenario,
52
+ type PreviewStoryReference,
53
+ } from './catalog.js';
54
+ import {
55
+ cleanRecord,
56
+ DEFAULT_SLOT,
57
+ ensureIds,
58
+ insertAt,
59
+ isContainer,
60
+ moveNode,
61
+ nodeAt,
62
+ pathKey,
63
+ removeAt,
64
+ resolveSortableMove,
65
+ seedComponentNode,
66
+ seedRoot,
67
+ slotsKey,
68
+ stableStringify,
69
+ toPayload,
70
+ updateAt,
71
+ type DropTarget,
72
+ type EditorNode,
73
+ type EditorState,
74
+ type SortableListAddress,
75
+ type Step,
76
+ } from './editor-tree.js';
77
+ import type { CodeBlockContent, CodeBlockLanguage } from '../views.js';
78
+ import type { RegenerationTransport } from './regeneration.js';
79
+ import type {
80
+ HtmlSourceRequest,
81
+ HtmlSourceResponse,
82
+ HtmlSourceTransport,
83
+ ScenarioHtmlSourceLoader,
84
+ } from './html-source.js';
85
+ import {
86
+ isPlaceholder,
87
+ printSourceStory,
88
+ resolveSourceStory,
89
+ type PreviewTargetCode,
90
+ } from './source.js';
91
+ import type { PreviewPersistence } from '../lib/types.js';
92
+ import {
93
+ changedProps,
94
+ isPaneWidth,
95
+ parseTargets,
96
+ parseOptionValue,
97
+ parsePaneWidth,
98
+ parseProps,
99
+ readStoredOptions,
100
+ readUrlState,
101
+ storageKey,
102
+ writeStoredOptions,
103
+ writeUrlState,
104
+ type PreviewStorage,
105
+ } from './persistence.js';
106
+
107
+ /** The time edits coalesce for before the preview regenerates what they
108
+ * change, in milliseconds: the scratch story, and a story's html from the
109
+ * development server. */
110
+ export const REGENERATION_DEBOUNCE_MILLISECONDS = 250;
111
+
112
+ /** The story id the regenerated scratch story is mounted under. */
113
+ export const SCRATCH_STORY_ID = 'scratch';
114
+
115
+ /**
116
+ * The attribute the controller sets on an iframe it mounted once the iframe
117
+ * shows its first content, and takes away while it loads again. Until then
118
+ * the iframe is hidden; a shell's CSS can show a loading note beside one
119
+ * without it.
120
+ */
121
+ export const PREVIEW_FRAME_SHOWN_ATTRIBUTE = 'data-preview-shown';
122
+
123
+ /**
124
+ * An option a shell shows a control for: one of the kit's, or one the shell
125
+ * declared for itself, whose id is namespaced under `shell.` and whose value
126
+ * stays in the host page.
127
+ */
128
+ export interface PreviewOptionDescriptor extends Omit<PreviewOption, 'apply'> {
129
+ /** Whether the option is the kit's or the shell's own. */
130
+ origin: 'kit' | 'shell';
131
+ }
132
+
133
+ /** What the preview shows: a component story or a custom view. */
134
+ export type PreviewSelection =
135
+ | { kind: 'component'; id: string }
136
+ | { kind: 'view'; id: string };
137
+
138
+ /** A preview iframe: a scripted target's pane, the html pane or the custom-view pane. */
139
+ export type PreviewFrameTarget = PreviewTarget | 'view';
140
+
141
+ /** Whether a mounted iframe has announced that it listens for messages. */
142
+ export type PreviewFrameReadiness = 'loading' | 'ready';
143
+
144
+ /** One call a story made of one of its events, as a pane recorded it. */
145
+ export interface PreviewEvent {
146
+ /** Its number among the events recorded, for a stable key. */
147
+ id: number;
148
+ /** The pane it was recorded in. */
149
+ target: PreviewFrameTarget;
150
+ /** The event: the function prop the story called. */
151
+ name: string;
152
+ /** A readable, one-line summary of what it was called with. */
153
+ payload: string;
154
+ /** When, in milliseconds since the epoch. */
155
+ time: number;
156
+ }
157
+
158
+ /** How many recorded events `state.events` keeps. */
159
+ const EVENT_LOG_LENGTH = 100;
160
+
161
+ /** Where the regeneration of the scratch story stands. */
162
+ export interface PreviewRegenerationStatus {
163
+ /** `pending` from an edit until the story is written and the panes reloaded. */
164
+ status: 'idle' | 'pending' | 'error';
165
+ /** Why the last regeneration failed, while `status` is `error`. */
166
+ message?: string;
167
+ }
168
+
169
+ /** The scenario editor's state. */
170
+ export interface PreviewEditorState {
171
+ /** Whether a shell shows the editor. */
172
+ open: boolean;
173
+ /** The tree being edited: the story's component, its props and its slot
174
+ * content. Present while the editor is open or the Empty scenario is shown. */
175
+ tree?: EditorState;
176
+ /**
177
+ * Whether the tree's slot content differs from what it was seeded with:
178
+ * a node inserted, removed, moved or replaced, a nested node's props or a
179
+ * text changed. The root's props are not counted, as they live in `props`
180
+ * and the address. Content is compared by value, so an edit undone by hand
181
+ * counts as no change. A change of the root's text, which `props` shows as
182
+ * `children`, counts too. `false` without a tree, and again once the tree
183
+ * is seeded afresh (another story, or another scenario with the editor
184
+ * open). While it is `true` the address may not reproduce the panes, which
185
+ * show the edited content.
186
+ */
187
+ modified: boolean;
188
+ }
189
+
190
+ /**
191
+ * Where the html source of what the panes show stands: `ready` with the
192
+ * markup, `loading` while it is being printed, or `unavailable` with the
193
+ * reason, such as a story holding a part that has no markup, or a static
194
+ * build asked for props it printed no html for.
195
+ */
196
+ export type PreviewHtmlSource =
197
+ | {
198
+ status: 'ready';
199
+ source: string;
200
+ /** The styles the kit's build wrote for the components the markup
201
+ * renders, which the html pane shows it under. */
202
+ stylesheet?: string;
203
+ /** What the markup could not work out at rest and left out, such as
204
+ * an expression reading the component's script state, so that part
205
+ * shows at a default. Absent when there is nothing to name. */
206
+ warnings?: string[];
207
+ }
208
+ | { status: 'loading' }
209
+ | { status: 'unavailable'; reason: string };
210
+
211
+ /** The code of what the selection's panes show. */
212
+ export interface PreviewCode {
213
+ /**
214
+ * The code a consumer writes for the story, per target the story
215
+ * renders in (`targets.supported`), html aside: the component with the props the
216
+ * panes show and its slot content, or the editor's tree while it holds
217
+ * one, importing from the kit's package entry for each target. A prop is
218
+ * left out while it holds the component's declared default. Printed as the
219
+ * state changes, so it is always present.
220
+ */
221
+ targets: PreviewTargetCode;
222
+ /** The markup the story renders to, at rest, for a reader who works
223
+ * without a framework. */
224
+ html: PreviewHtmlSource;
225
+ }
226
+
227
+ /**
228
+ * The preview's state as a shell reads it. Every field is plain data: a
229
+ * snapshot is immutable, and a new one replaces it on each change, with the
230
+ * unchanged fields kept by reference.
231
+ */
232
+ export interface PreviewState {
233
+ selection: PreviewSelection;
234
+ /** The selected scenario's id; `''` is the anonymous default. */
235
+ scenario: string;
236
+ /**
237
+ * The root props the selection's panes show now: the scenario's over the
238
+ * component's defaults, then whatever `setProps` or the editor's root set.
239
+ * While the editor holds a tree, a string `children` the story draws its
240
+ * default slot from is the root's text: `children` is the text while the
241
+ * root's default slot holds one text node and nothing else, and absent
242
+ * otherwise, as other content in that slot takes its place on screen. A
243
+ * story with default-slot content of its own shows that content and not
244
+ * `children`, so its props carry no `children` then. While a view is
245
+ * selected, the last story's.
246
+ */
247
+ props: Record<string, unknown>;
248
+ /** Every option's current value, the story's own defaults applied, keyed by
249
+ * option id; a shell's own options under their `shell.` ids. */
250
+ options: Record<string, OptionValue>;
251
+ /** The options a shell hides the controls of: those the selected story marks
252
+ * unavailable, and every story option while a view is selected. */
253
+ unavailableOptions: string[];
254
+ /**
255
+ * The story panes, by what each renders in: the kit's scripted targets, and
256
+ * `html` after them where the kit builds html. The html pane shows the
257
+ * markup in `code.html`, with the kit's stylesheets and options and no
258
+ * behavior; every story supports it.
259
+ */
260
+ targets: {
261
+ /** What the selected story renders in, among the kit's. */
262
+ supported: PreviewTarget[];
263
+ /** What is turned on among the kit's, whether or not the story supports it. */
264
+ visible: PreviewTarget[];
265
+ /** The panes a shell mounts: the supported ones that are turned on, in order. */
266
+ panes: PreviewTarget[];
267
+ };
268
+ /**
269
+ * The width a shell lays each of the selection's story panes out at, in
270
+ * CSS pixels, so a component that responds to its width shows as it does
271
+ * on a screen that wide: one of `catalog.paneWidths`, or any width a link
272
+ * names. Absent while the panes fit the space the shell gives them. A
273
+ * shell decides how a pane wider than its space shows, such as scrolling
274
+ * across it. It outlasts the story, and a view's pane does not follow it.
275
+ */
276
+ paneWidth?: number;
277
+ /** The readiness of the selection's panes, by target (`html` among
278
+ * them), and of the view pane under `view`. */
279
+ frames: Partial<Record<PreviewFrameTarget, PreviewFrameReadiness>>;
280
+ /** The readiness of the detached story panes `frames.mountStory` mounted,
281
+ * by the key `storyFrameKey(target, story)` gives. */
282
+ storyFrames: Record<string, PreviewFrameReadiness>;
283
+ /**
284
+ * The size of what each of the selection's panes shows, by target, and
285
+ * of the view pane under `view`, as the pane last reported it: its story
286
+ * or view with the space the kit's canvas puts around it, in whole CSS
287
+ * pixels. A shell that sizes a pane to its content sets the iframe's height
288
+ * to `height`. An entry appears once the pane has rendered, keeps its last
289
+ * size while the pane reloads, and goes when the pane is unmounted. The
290
+ * width is the content's at the pane's current width, as block content
291
+ * fills the pane.
292
+ */
293
+ frameSizes: Partial<Record<PreviewFrameTarget, PreviewContentSize>>;
294
+ /** What each of the selection's panes threw as its story rendered, by
295
+ * target: the error's message, shown in the story's place. An entry
296
+ * goes when the pane mounts a story that renders, and when the pane is
297
+ * unmounted. */
298
+ frameErrors: Partial<Record<PreviewFrameTarget, string>>;
299
+ /** The events the selection's panes recorded, oldest first and the last
300
+ * hundred at most: each call a story made of one of its events
301
+ * (`PreviewComponent.events`). Cleared when another story or a view is
302
+ * selected, and by `actions.clearEvents`. */
303
+ events: PreviewEvent[];
304
+ /** The size of what each detached story pane shows, as `frameSizes` holds
305
+ * the selection's, by the key `storyFrameKey(target, story)` gives. */
306
+ storyFrameSizes: Record<string, PreviewContentSize>;
307
+ editor: PreviewEditorState;
308
+ regeneration: PreviewRegenerationStatus;
309
+ /** The code of what the selection's panes show, per target and as
310
+ * html. Absent while a view is selected. */
311
+ code?: PreviewCode;
312
+ }
313
+
314
+ /** What `createNode` makes: a text leaf, a plain HTML tag, or a component. */
315
+ export type PreviewNodeRequest =
316
+ | { kind: 'text'; value: string }
317
+ | { kind: 'html'; tag: string }
318
+ | {
319
+ kind: 'component';
320
+ /** The component's export name. */
321
+ name: string;
322
+ /** What the component comes in with: nothing (`empty`, the default), its
323
+ * curated defaults and slot content (`default`), or a scenario's props
324
+ * over its defaults. A component with no slot content and no scenarios
325
+ * always comes in with its defaults. */
326
+ importAs?: 'empty' | 'default' | { scenario: string };
327
+ };
328
+
329
+ /** Where a node is inserted: into `slot` of the node at `parent` (the root's
330
+ * slots when empty), before `index`, or at the end when `index` is omitted. */
331
+ export interface PreviewInsertTarget {
332
+ parent: Step[];
333
+ slot: string;
334
+ index?: number;
335
+ }
336
+
337
+ /** What a shell's sortable lists report when a node is dropped. */
338
+ export interface PreviewSortableDrop {
339
+ /** The list the node was dragged from. */
340
+ source: SortableListAddress;
341
+ /** The list it was dropped in. */
342
+ target: SortableListAddress;
343
+ /** Whether the two are the same list. */
344
+ sameList: boolean;
345
+ /** The node's index in the source list. */
346
+ fromIndex: number;
347
+ /** The index it was dropped at in the target list. */
348
+ toIndex: number;
349
+ }
350
+
351
+ /** The timers the controller debounces with; the global ones by default. */
352
+ export interface PreviewTimers {
353
+ setTimeout(callback: () => void, milliseconds: number): unknown;
354
+ clearTimeout(handle: unknown): void;
355
+ }
356
+
357
+ /** What `createPreviewController` takes. */
358
+ export interface PreviewControllerOptions {
359
+ /** The kit's catalog. */
360
+ catalog: PreviewCatalog;
361
+ /** The kit's preview options. */
362
+ options: PreviewOption[];
363
+ /** The shell's own options, as it declares them; the controller namespaces
364
+ * their ids under `shell.`. */
365
+ shellOptions?: PreviewShellOption[];
366
+ /** Carries the scratch story to whatever writes it. Without one, the
367
+ * preview cannot regenerate and `capabilities.regeneration` is `false`. */
368
+ regeneration?: RegenerationTransport;
369
+ /** Prints the html of any story, as the development server does. Asked
370
+ * once a story's changes pause, and aborted when the story changes again. */
371
+ htmlSource?: HtmlSourceTransport;
372
+ /** Reads the html printed ahead of time for each scenario, as the static
373
+ * build serves it. Used without `htmlSource`; the html of props that differ
374
+ * from a scenario's, or of the editor's tree, is then unavailable. */
375
+ scenarioHtmlSources?: ScenarioHtmlSourceLoader;
376
+ /** The window the iframes post their messages to, and whose location holds
377
+ * the preview's query parameters; the global one by default. */
378
+ window?: Window;
379
+ /** Where the controller remembers what a person chose: the query parameters
380
+ * alone (`'url'`, the default), or also the global option values in local
381
+ * storage, per kit name (`'url-and-local-storage'`). */
382
+ persistence?: PreviewPersistence;
383
+ /** The storage `'url-and-local-storage'` uses; the window's local storage by default. */
384
+ storage?: PreviewStorage;
385
+ /** The timers to debounce with; the global ones by default. */
386
+ timers?: PreviewTimers;
387
+ /** The URL the preview's pages are served under, ending in `/`, such as
388
+ * `https://example.com/kit/`: the iframes load their pages from it. It
389
+ * is fixed when the controller is created, so a shell that changes the
390
+ * host page's path or hash for its own routing does not move the iframes.
391
+ * `/`, the root of the host page's origin, by default. */
392
+ baseUrl?: string;
393
+ }
394
+
395
+ /** The preview's behavior, for a shell to render. */
396
+ export interface PreviewController {
397
+ /** The version of this contract. */
398
+ readonly apiVersion: 1;
399
+ /** The kit's catalog, static for the controller's lifetime. */
400
+ readonly catalog: PreviewCatalog;
401
+ /** The options a shell shows controls for: the kit's, then the shell's own. */
402
+ readonly options: PreviewOptionDescriptor[];
403
+ readonly capabilities: {
404
+ /** Whether the scratch story can be regenerated, which the editor and the
405
+ * Empty scenario need. False in a static build. */
406
+ regeneration: boolean;
407
+ };
408
+ /** The current state. */
409
+ getState(): PreviewState;
410
+ /**
411
+ * Call `listener` with each new state.
412
+ *
413
+ * @returns A function that stops the calls.
414
+ */
415
+ subscribe(listener: (state: PreviewState) => void): () => void;
416
+ actions: {
417
+ /**
418
+ * Show a component's story, on its baseline scenario, or with `props`
419
+ * over its defaults when given. The option values the baseline scenario
420
+ * sets are set as `pickScenario` sets them. A component that is not
421
+ * standalone and an unknown id are ignored.
422
+ */
423
+ selectComponent(id: string, props?: Record<string, unknown>): void;
424
+ /** Show a custom view. Unknown ids are ignored. */
425
+ selectView(id: string): void;
426
+ /**
427
+ * Show what `selection` names: a component's story on its baseline
428
+ * scenario, or a custom view. One navigation control can hand over the
429
+ * selection it lists without telling the two apart.
430
+ *
431
+ * @example
432
+ * ```ts
433
+ * controller.actions.select({ kind: 'view', id: 'swatches' });
434
+ * ```
435
+ */
436
+ select(selection: PreviewSelection): void;
437
+ /**
438
+ * Pick a scenario of the selected story; `''` is the anonymous default.
439
+ * The option values the scenario sets (`PreviewScenario.options`) are set
440
+ * as `setOption` sets them, so the panes, the controls and the address
441
+ * follow; the values it does not set stay as they are, and picking
442
+ * another scenario keeps them, apart from those the next one sets. The
443
+ * Empty scenario is ignored where the preview cannot regenerate.
444
+ */
445
+ pickScenario(id: string): void;
446
+ /** Set an option's value. A story option's value lasts until another story is selected. */
447
+ setOption(id: string, value: OptionValue): void;
448
+ /**
449
+ * Replace the selected story's root props with `props`, which are not
450
+ * merged into the current ones. They reach the panes live, with no
451
+ * regeneration, whether or not the editor is open; the editor's root
452
+ * takes them too. While the editor holds a tree, `children` is the
453
+ * root's text, as `state.props` shows it: a new string replaces the
454
+ * content of the root's default slot with that one text, and leaving
455
+ * `children` out removes a lone text, each an edit of the tree's content
456
+ * that regenerates the story; content other than a lone text stays while
457
+ * `children` is left out. A story with default-slot content of its own
458
+ * takes no `children` then. Props that are `undefined` are dropped. Ignored
459
+ * while a view is selected; picking a scenario or a story seeds them
460
+ * afresh.
461
+ *
462
+ * @example
463
+ * ```ts
464
+ * const { props } = controller.getState();
465
+ * controller.actions.setProps({ ...props, variant: 'ghost' });
466
+ * ```
467
+ */
468
+ setProps(props: Record<string, unknown>): void;
469
+ /** Turn a scripted target's pane, or the html pane, on or off. The choice
470
+ * outlasts the story. */
471
+ setTargetVisible(target: PreviewTarget, visible: boolean): void;
472
+ /**
473
+ * Lay the story panes out at `width` CSS pixels, or, with `undefined`,
474
+ * fit them to the shell's space again (`state.paneWidth`). A width that
475
+ * is not a whole number from 1 to `MAXIMUM_PANE_WIDTH` (7680) is
476
+ * ignored. The choice outlasts the story.
477
+ *
478
+ * @example
479
+ * ```ts
480
+ * const medium = controller.catalog.paneWidths.find((paneWidth) => paneWidth.label === 'medium');
481
+ * controller.actions.setPaneWidth(medium?.width);
482
+ * ```
483
+ */
484
+ setPaneWidth(width: number | undefined): void;
485
+ /** Empty the log of recorded events. */
486
+ clearEvents(): void;
487
+ };
488
+ editor: {
489
+ /** Open the editor on the selected story, seeding its tree from the story's content. */
490
+ open(): void;
491
+ /** Close the editor. Its tree stays as it is until another story or scenario is selected. */
492
+ close(): void;
493
+ /**
494
+ * Make a node to insert, by the catalog's component data.
495
+ *
496
+ * @param request - What to make: a text leaf, an HTML tag, or a
497
+ * component by its export name.
498
+ * @returns The tree's node: `{ kind: 'text', value }`,
499
+ * `{ kind: 'html', tag, props, slots }`, or, for a component,
500
+ * `{ kind: 'element', component, props, slots }`.
501
+ */
502
+ createNode(request: PreviewNodeRequest): EditorNode;
503
+ insert(target: PreviewInsertTarget, node: EditorNode): void;
504
+ /** Move the node at `from` to `to`, whose index is counted before the node
505
+ * is taken out, as a drop reports it; a move into the node's own subtree
506
+ * is ignored. */
507
+ move(from: Step[], to: DropTarget): void;
508
+ /**
509
+ * Move the node at `path` within its slot by `offset` places: `-1` one
510
+ * up, `1` one down. The move stops at either end of the slot, and a node
511
+ * already there stays put.
512
+ *
513
+ * @example
514
+ * ```ts
515
+ * controller.editor.moveBy(path, -1); // one place up
516
+ * ```
517
+ */
518
+ moveBy(path: Step[], offset: number): void;
519
+ /** Put `node` in place of the node at `path`. */
520
+ replace(path: Step[], node: EditorNode): void;
521
+ remove(path: Step[]): void;
522
+ /** Replace the props of the node at `path` with `props`, which are not
523
+ * merged into the node's current ones. The empty path is the root: it
524
+ * does what `actions.setProps(props)` does, live, without a regeneration,
525
+ * except that the root's content, and so its `children`, stays as it
526
+ * is, as the tree's root props never hold `children`. */
527
+ setNodeProps(path: Step[], props: Record<string, unknown>): void;
528
+ /** Set the value of the text node at `path`. */
529
+ setText(path: Step[], text: string): void;
530
+ /** The move a sortable drop amounts to. */
531
+ resolveDrop(drop: PreviewSortableDrop): { from: Step[]; to: DropTarget };
532
+ };
533
+ frames: {
534
+ /**
535
+ * Mount a scripted target's pane, or the html pane, inside `element`: an
536
+ * iframe with no size of its own, which the shell sizes. The controller
537
+ * owns the iframe: it loads it, reloads a scripted target's after each
538
+ * regeneration and answers it. The html pane shows the markup of
539
+ * `code.html` as it changes, keeping what it shows while the next is
540
+ * printed and saying why when there is none; it runs none of the story's
541
+ * behavior. The pane's readiness reaches subscribers after this call
542
+ * returns, never during it, and the size of what it shows reaches
543
+ * `frameSizes` once it renders.
544
+ *
545
+ * @returns A function that unmounts the pane.
546
+ */
547
+ mount(element: HTMLElement, target: PreviewTarget): () => void;
548
+ /**
549
+ * Mount a custom view's pane inside `element`, as `mount` mounts a
550
+ * target's: a bare iframe the shell sizes, its readiness reported after
551
+ * this call returns.
552
+ *
553
+ * @returns A function that unmounts the pane.
554
+ */
555
+ mountView(element: HTMLElement, viewId: string): () => void;
556
+ /**
557
+ * Mount a read-only pane of any story inside `element`, apart from the
558
+ * selection: a bare iframe the shell sizes, as `mount` makes. Its props
559
+ * are the scenario's over the component's defaults, then `story.props`
560
+ * over those. `scenario` omitted is the story's starting scenario, as
561
+ * selecting the story picks it, and `''` the anonymous default. The pane
562
+ * follows the global options; its story options are the component's own,
563
+ * as selecting it would seed them, and the option values its scenario
564
+ * sets stay over both. It never shows the editor's content and is not
565
+ * reloaded by a regeneration. Its readiness is reported in
566
+ * `storyFrames` under `storyFrameKey(target, story)`, after this call
567
+ * returns, and the size of what it shows in `storyFrameSizes` under the
568
+ * same key.
569
+ *
570
+ * @param element - The element to append the iframe to.
571
+ * @param target - The target to render the story in.
572
+ * @param story - The story: a browseable component's id, a scenario and props.
573
+ * @returns A function that unmounts the pane.
574
+ * @throws When the component is unknown or not standalone, the target is
575
+ * not among its targets, or the scenario is unknown or the Empty one,
576
+ * which renders through the selection's regenerated story.
577
+ * @example
578
+ * ```ts
579
+ * for (const scenario of component.scenarios.filter((scenario) => !scenario.empty)) {
580
+ * const cell = grid.appendChild(document.createElement('div'));
581
+ * controller.frames.mountStory(cell, 'react', { component: component.id, scenario: scenario.id });
582
+ * }
583
+ * ```
584
+ */
585
+ mountStory(
586
+ element: HTMLElement,
587
+ target: PreviewScriptedTarget,
588
+ story: PreviewStoryReference
589
+ ): () => void;
590
+ /**
591
+ * Mount the kit's code block inside `element`, showing `target`'s source
592
+ * of the selection: an iframe the shell sizes, which the block fills and
593
+ * scrolls as it likes. It follows the source as the story, its props and
594
+ * its content change, and the global option values: a story option sets
595
+ * nothing in a code block. For a kit that names a code block
596
+ * (`catalog.codeBlock`); its readiness and size are the block's own and
597
+ * reach no state.
598
+ *
599
+ * @returns A function that unmounts the block.
600
+ */
601
+ mountCode(element: HTMLElement, target: PreviewTarget): () => void;
602
+ /**
603
+ * Tell the kit's code blocks whether the shell wraps its code panes'
604
+ * long lines, as `wrap` in their content; false until a shell says. A
605
+ * shell with a wrap choice calls it as the choice changes.
606
+ */
607
+ wrapCode(wrap: boolean): void;
608
+ /**
609
+ * Load the selection's pane for `target` again: its readiness goes back
610
+ * to loading until it answers, and it shows the selection as it is now.
611
+ */
612
+ reload(target: PreviewFrameTarget): void;
613
+ /**
614
+ * The address of the selection's pane for a target opened on its own,
615
+ * outside the preview: the story it shows now - the scratch story while
616
+ * the editor's content renders - with its props and the option values.
617
+ * It follows nothing after it opens.
618
+ */
619
+ standaloneUrl(target: PreviewScriptedTarget): string;
620
+ };
621
+ /** Unmount every pane, drop a pending regeneration and stop listening. */
622
+ dispose(): void;
623
+ }
624
+
625
+ /** Each target's source language, as a code block's highlighter names it. */
626
+ const CODE_LANGUAGES: Record<PreviewTarget, CodeBlockLanguage> = {
627
+ react: 'tsx',
628
+ vue: 'vue',
629
+ svelte: 'svelte',
630
+ html: 'html',
631
+ };
632
+
633
+ interface MountedFrame {
634
+ target: PreviewFrameTarget;
635
+ iframe: HTMLIFrameElement;
636
+ /** A detached story pane's story; absent for the selection's panes. */
637
+ story?: {
638
+ /** Its readiness key in `storyFrames`. */
639
+ key: string;
640
+ component: PreviewComponent;
641
+ /** The id the pane mounts the story by: the component's, or its
642
+ * scenario's own story when that has content of its own. */
643
+ id: string;
644
+ props: Record<string, unknown>;
645
+ /** The option values its scenario sets, over the global ones. */
646
+ options: Record<string, OptionValue>;
647
+ };
648
+ /** The newest of the pane's own write-backs the host has taken (the
649
+ * `write` its events carry), which each props message to it answers;
650
+ * 0 before the first, and again once the pane announces ready. */
651
+ answered?: number;
652
+ }
653
+
654
+ /** A story's props without its slot content, which the Empty scenario strips. */
655
+ function withoutContent(
656
+ props: Record<string, unknown>
657
+ ): Record<string, unknown> {
658
+ const { children: _children, ...rest } = props;
659
+ return rest;
660
+ }
661
+
662
+ /** The text of a tree's root when its default slot holds one text node and
663
+ * nothing else. */
664
+ function rootText(tree: EditorState): string | undefined {
665
+ const nodes = tree.slots[DEFAULT_SLOT];
666
+ return nodes?.length === 1 && nodes[0].kind === 'text'
667
+ ? nodes[0].value
668
+ : undefined;
669
+ }
670
+
671
+ function withoutUndefined(
672
+ props: Record<string, unknown>
673
+ ): Record<string, unknown> {
674
+ const out: Record<string, unknown> = {};
675
+ for (const [name, value] of Object.entries(props))
676
+ if (value !== undefined) out[name] = value;
677
+ return out;
678
+ }
679
+
680
+ function isRecord(value: unknown): value is Record<string, unknown> {
681
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
682
+ }
683
+
684
+ function sameTargets(a: PreviewTarget[], b: PreviewTarget[]): boolean {
685
+ return (
686
+ a.length === b.length && a.every((target, index) => target === b[index])
687
+ );
688
+ }
689
+
690
+ const DEFAULT_TIMERS: PreviewTimers = {
691
+ setTimeout: (callback, milliseconds) => setTimeout(callback, milliseconds),
692
+ clearTimeout: (handle) =>
693
+ clearTimeout(handle as ReturnType<typeof setTimeout>),
694
+ };
695
+
696
+ /**
697
+ * The props a scripted pane mounts its story with: the story's, less each
698
+ * prop it unsets with `null`, which the pane leaves out as the story's code
699
+ * does, so the component's own default applies. A two-way prop given `null`
700
+ * stays: the story controls it at `null`, as its code binds it.
701
+ *
702
+ * @param props - The story's root props.
703
+ * @param component - The story's root component, for its two-way props.
704
+ * @returns The props without those unset with `null`.
705
+ */
706
+ function paneProps(
707
+ props: Record<string, unknown>,
708
+ component: Pick<PreviewComponent, 'bindings'> | undefined
709
+ ): Record<string, unknown> {
710
+ const twoWay = new Set(
711
+ (component?.bindings ?? []).map((binding) => binding.prop)
712
+ );
713
+ return Object.fromEntries(
714
+ Object.entries(props).filter(
715
+ ([name, value]) => value !== null || twoWay.has(name)
716
+ )
717
+ );
718
+ }
719
+
720
+ /**
721
+ * Create a preview controller.
722
+ *
723
+ * @param controllerOptions - The catalog, the options and the environment.
724
+ * @returns The controller.
725
+ * @example
726
+ * ```ts
727
+ * const controller = createPreviewController({
728
+ * catalog: catalogFromManifest(manifest, views),
729
+ * options: previewOptions,
730
+ * regeneration: createFetchRegenerationTransport(),
731
+ * });
732
+ * const stop = controller.subscribe((state) => render(state));
733
+ * controller.actions.selectComponent('components-button');
734
+ * ```
735
+ */
736
+ export function createPreviewController(
737
+ controllerOptions: PreviewControllerOptions
738
+ ): PreviewController {
739
+ const { catalog, regeneration } = controllerOptions;
740
+ const options: PreviewOptionDescriptor[] = [
741
+ ...controllerOptions.options.map(({ apply: _apply, ...option }) => ({
742
+ ...option,
743
+ origin: 'kit' as const,
744
+ })),
745
+ ...(controllerOptions.shellOptions ?? []).map((option) => ({
746
+ ...option,
747
+ id: shellOptionId(option.id),
748
+ scope: 'global' as const,
749
+ origin: 'shell' as const,
750
+ })),
751
+ ];
752
+ const shellOptionIds = new Set(
753
+ options
754
+ .filter((option) => option.origin === 'shell')
755
+ .map((option) => option.id)
756
+ );
757
+ const hostWindow =
758
+ controllerOptions.window ?? (globalThis as { window?: Window }).window;
759
+ const timers = controllerOptions.timers ?? DEFAULT_TIMERS;
760
+ const baseUrl = controllerOptions.baseUrl ?? '/';
761
+ const storage =
762
+ controllerOptions.persistence === 'url-and-local-storage'
763
+ ? resolveStorage()
764
+ : undefined;
765
+ const storedOptionsKey = storageKey(catalog.kitName);
766
+
767
+ function resolveStorage(): PreviewStorage | undefined {
768
+ if (controllerOptions.storage) return controllerOptions.storage;
769
+ try {
770
+ // Reading `localStorage` itself throws where storage is blocked.
771
+ return hostWindow?.localStorage ?? undefined;
772
+ } catch {
773
+ return undefined;
774
+ }
775
+ }
776
+
777
+ const listeners = new Set<(state: PreviewState) => void>();
778
+ const frames: MountedFrame[] = [];
779
+
780
+ const stories = catalog.components.filter(
781
+ (component) => component.standalone
782
+ );
783
+ // The stories a reader browses: an unlisted one is reachable at its address
784
+ // and selectable by id, but never listed or selected first.
785
+ const listedStories = stories.filter((component) => !component.unlisted);
786
+ const componentById = (id: string): PreviewComponent | undefined =>
787
+ catalog.components.find((component) => component.id === id);
788
+ const componentByName = (name: string): PreviewComponent | undefined =>
789
+ catalog.components.find((component) => component.name === name);
790
+
791
+ // The catalog a custom view builds its context from: the browseable stories.
792
+ const catalogPayload: PreviewCatalogPayload = {
793
+ kitName: catalog.kitName,
794
+ sources: catalog.sources,
795
+ components: listedStories.map((component) => ({
796
+ id: component.id,
797
+ title: component.title,
798
+ source: component.source,
799
+ })),
800
+ };
801
+
802
+ // --- what the state derives from --------------------------------------------
803
+ let globalValues = optionDefaults(options, 'global');
804
+ let storyOverrides: Record<string, OptionValue> = {};
805
+ const kitTargets = PREVIEW_TARGETS.filter((target) =>
806
+ catalog.targets.includes(target)
807
+ );
808
+ const visibleTargets = new Set<PreviewTarget>(PREVIEW_TARGETS);
809
+ const storyOptionIds = options
810
+ .filter((option) => option.scope === 'story')
811
+ .map((option) => option.id);
812
+ // The root props the selected scenario seeded: the address carries the
813
+ // props only while they differ from these.
814
+ let seededProps: Record<string, unknown> = {};
815
+ // The editor tree's slot content as it was seeded, for `editor.modified`.
816
+ let seededSlots = '';
817
+ // Whether the panes render the regenerated scratch story or the real one.
818
+ let scratchActive = false;
819
+ // The panes' story mounts so far: each set-story the host broadcasts is one.
820
+ let storyMount = 0;
821
+ let reloadCount = 0;
822
+ let regenerationTimer: unknown = null;
823
+ let regenerationSequence = 0;
824
+
825
+ function storyDefaultsFor(
826
+ component: PreviewComponent | undefined
827
+ ): Record<string, OptionValue> {
828
+ const base = optionDefaults(options, 'story');
829
+ for (const [id, value] of Object.entries(component?.options ?? {})) {
830
+ if (value !== false) base[id] = value;
831
+ }
832
+ return base;
833
+ }
834
+
835
+ function unavailableOptionsFor(
836
+ component: PreviewComponent | undefined
837
+ ): string[] {
838
+ return Object.entries(component?.options ?? {})
839
+ .filter(([, value]) => value === false)
840
+ .map(([id]) => id);
841
+ }
842
+
843
+ function mergedOptions(
844
+ component: PreviewComponent | undefined
845
+ ): Record<string, OptionValue> {
846
+ return {
847
+ ...globalValues,
848
+ ...storyDefaultsFor(component),
849
+ ...storyOverrides,
850
+ };
851
+ }
852
+
853
+ /** The values the iframes apply: the kit's options alone. */
854
+ function kitValues(
855
+ values: Record<string, OptionValue>
856
+ ): Record<string, OptionValue> {
857
+ if (shellOptionIds.size === 0) return values;
858
+ const kit: Record<string, OptionValue> = {};
859
+ for (const [id, value] of Object.entries(values))
860
+ if (!shellOptionIds.has(id)) kit[id] = value;
861
+ return kit;
862
+ }
863
+
864
+ /** The kit's global option values, for a code block: it shows source, not
865
+ * a story, so a story option has nothing there to set. */
866
+ function codeValues(
867
+ values: Record<string, OptionValue>
868
+ ): Record<string, OptionValue> {
869
+ const global = { ...kitValues(values) };
870
+ for (const id of storyOptionIds) delete global[id];
871
+ return global;
872
+ }
873
+
874
+ /** The values a detached pane of `component` applies: the global values,
875
+ * the story options as selecting the component would seed them, and the
876
+ * values its scenario sets over those. */
877
+ function detachedOptions(
878
+ component: PreviewComponent,
879
+ scenarioValues: Record<string, OptionValue> = {}
880
+ ): Record<string, OptionValue> {
881
+ return kitValues({
882
+ ...globalValues,
883
+ ...storyDefaultsFor(component),
884
+ ...scenarioValues,
885
+ });
886
+ }
887
+
888
+ /** The option values a scenario of `component` sets that apply to it: the
889
+ * kit's options it names, with a value each accepts, less those the
890
+ * component makes unavailable. */
891
+ function scenarioOptionValues(
892
+ component: PreviewComponent,
893
+ scenarioId: string
894
+ ): Record<string, OptionValue> {
895
+ const carried = component.scenarios.find(
896
+ (candidate) => candidate.id === scenarioId
897
+ )?.options;
898
+ const values: Record<string, OptionValue> = {};
899
+ if (!carried) return values;
900
+ const unavailable = unavailableOptionsFor(component);
901
+ for (const [id, value] of Object.entries(carried)) {
902
+ const option = options.find(
903
+ (candidate) => candidate.id === id && candidate.origin === 'kit'
904
+ );
905
+ if (
906
+ option &&
907
+ !unavailable.includes(id) &&
908
+ acceptsOptionValue(option, value)
909
+ )
910
+ values[id] = value;
911
+ }
912
+ return values;
913
+ }
914
+
915
+ /**
916
+ * Set the option values a scenario of `component` carries, the way
917
+ * `setOption` sets a value: a global one for the session, a story one over
918
+ * the story's own until another story is selected. Picking a scenario
919
+ * that carries none leaves every value as it is.
920
+ *
921
+ * @returns Which kinds of value changed: whether any did, and whether a
922
+ * global one did, which detached panes follow.
923
+ */
924
+ function applyScenarioOptions(
925
+ component: PreviewComponent,
926
+ scenarioId: string
927
+ ): { changed: boolean; globalChanged: boolean } {
928
+ const values = scenarioOptionValues(component, scenarioId);
929
+ let globalChanged = false;
930
+ for (const [id, value] of Object.entries(values)) {
931
+ const option = options.find((candidate) => candidate.id === id)!;
932
+ if (option.scope === 'story') {
933
+ storyOverrides = { ...storyOverrides, [id]: value };
934
+ } else {
935
+ globalChanged ||= globalValues[id] !== value;
936
+ globalValues = { ...globalValues, [id]: value };
937
+ }
938
+ }
939
+ return { changed: Object.keys(values).length > 0, globalChanged };
940
+ }
941
+
942
+ function targetsFor(
943
+ component: PreviewComponent | undefined
944
+ ): PreviewState['targets'] {
945
+ // Every story has an html pane where the kit builds html: one with no
946
+ // markup says why.
947
+ const supported = kitTargets.filter(
948
+ (target) =>
949
+ target === 'html' || (component?.targets.includes(target) ?? true)
950
+ );
951
+ const visible = kitTargets.filter((target) => visibleTargets.has(target));
952
+ return {
953
+ supported,
954
+ visible,
955
+ panes: visible.filter((target) => supported.includes(target)),
956
+ };
957
+ }
958
+
959
+ /** The props an address may set: those the component declares, and the
960
+ * text of its default slot as `children` when it has one. */
961
+ function addressableProps(
962
+ component: PreviewComponent
963
+ ): Pick<
964
+ PreviewComponent['propTypes'][number],
965
+ 'name' | 'control' | 'options'
966
+ >[] {
967
+ const declaresChildren = component.propTypes.some(
968
+ (propType) => propType.name === 'children'
969
+ );
970
+ return component.slots.default && !declaresChildren
971
+ ? [...component.propTypes, { name: 'children', control: 'text' }]
972
+ : component.propTypes;
973
+ }
974
+
975
+ /** The props as the address holds them: those that differ from the
976
+ * scenario's, and each prop of the scenario's the story leaves out as
977
+ * `null`, so that a link opens on what renders and stays short however
978
+ * much data the story's props hold. While the story renders from a tree
979
+ * (the editor, or Empty), `children` is the tree's content, which the
980
+ * address does not hold, so a missing `children` is not marked. */
981
+ function addressedProps(
982
+ component: PreviewComponent,
983
+ props: Record<string, unknown>
984
+ ): Record<string, unknown> {
985
+ const addressable = new Set(
986
+ addressableProps(component).map(({ name }) => name)
987
+ );
988
+ return changedProps(
989
+ props,
990
+ seededProps,
991
+ (name) =>
992
+ addressable.has(name) && !(name === 'children' && state.editor.tree)
993
+ );
994
+ }
995
+
996
+ /**
997
+ * Whether the story of the tree's root draws its default slot from the
998
+ * string `children` prop, having no default-slot content of its own. The
999
+ * editor holds that `children` as the root's text, so it can be edited,
1000
+ * wrapped or replaced like any node, and `state.props` shows the root's
1001
+ * lone text as `children`. A story with content of its own renders that
1002
+ * content and not `children`, so its tree's text is never `children`.
1003
+ */
1004
+ function drawsChildren(tree: EditorState): boolean {
1005
+ const root = componentByName(tree.root);
1006
+ const selected = selectedComponent();
1007
+ // The selection's own story is read as its scenario shows it.
1008
+ const shown =
1009
+ root !== undefined && selected?.name === root.name
1010
+ ? onScenario(root, state.scenario)
1011
+ : root;
1012
+ return !shown?.content?.default;
1013
+ }
1014
+
1015
+ /** The root props a tree shows: its own, and, where the story draws its
1016
+ * default slot from `children`, its root's lone text as `children`. */
1017
+ function treeProps(tree: EditorState): Record<string, unknown> {
1018
+ const text = drawsChildren(tree) ? rootText(tree) : undefined;
1019
+ return text === undefined ? tree.props : { ...tree.props, children: text };
1020
+ }
1021
+
1022
+ function seedProps(
1023
+ component: PreviewComponent,
1024
+ scenarioId: string
1025
+ ): Record<string, unknown> {
1026
+ const scenario = component.scenarios.find(
1027
+ (candidate) => candidate.id === scenarioId
1028
+ );
1029
+ const props = { ...component.defaultProps, ...(scenario?.props ?? {}) };
1030
+ // The placeholder text a component with no content starts from is the
1031
+ // preview's own; a scenario that fills the default slot replaces it.
1032
+ if (
1033
+ scenario?.content?.default !== undefined &&
1034
+ component.previewDefaults?.children !== undefined &&
1035
+ !('children' in (scenario.props ?? {}))
1036
+ ) {
1037
+ delete props.children;
1038
+ }
1039
+ return props;
1040
+ }
1041
+
1042
+ /** The scenario of `component` with slot content of its own, by id. */
1043
+ function contentScenario(
1044
+ component: PreviewComponent,
1045
+ scenarioId: string
1046
+ ): PreviewScenario | undefined {
1047
+ const scenario = component.scenarios.find(
1048
+ (candidate) => candidate.id === scenarioId
1049
+ );
1050
+ return scenario?.content === undefined ? undefined : scenario;
1051
+ }
1052
+
1053
+ /** The id the panes mount `component`'s story by on a scenario: the
1054
+ * scenario's own story when it has content of its own, else the
1055
+ * component's. */
1056
+ function paneStoryId(
1057
+ component: PreviewComponent,
1058
+ scenarioId: string
1059
+ ): string {
1060
+ return contentScenario(component, scenarioId)?.story ?? component.id;
1061
+ }
1062
+
1063
+ /** `component` as a scenario shows it: with the scenario's content in
1064
+ * place of the story's, for a scenario that has its own. */
1065
+ function onScenario(
1066
+ component: PreviewComponent,
1067
+ scenarioId: string
1068
+ ): PreviewComponent {
1069
+ const scenario = contentScenario(component, scenarioId);
1070
+ return scenario ? { ...component, content: scenario.content } : component;
1071
+ }
1072
+
1073
+ /** The component each scenario story renders, by the story's id. */
1074
+ const scenarioStoryComponents = new Map<string, PreviewComponent>(
1075
+ stories.flatMap((component) =>
1076
+ component.scenarios.flatMap((scenario) =>
1077
+ scenario.story === undefined
1078
+ ? []
1079
+ : [[scenario.story, component] as [string, PreviewComponent]]
1080
+ )
1081
+ )
1082
+ );
1083
+
1084
+ const first = listedStories[0] ?? stories[0];
1085
+ seededProps = first ? seedProps(first, defaultScenarioId(first)) : {};
1086
+ let state: PreviewState = {
1087
+ selection: { kind: 'component', id: first?.id ?? '' },
1088
+ scenario: defaultScenarioId(first),
1089
+ props: seededProps,
1090
+ options: mergedOptions(first),
1091
+ unavailableOptions: unavailableOptionsFor(first),
1092
+ targets: targetsFor(first),
1093
+ frames: {},
1094
+ storyFrames: {},
1095
+ frameSizes: {},
1096
+ frameErrors: {},
1097
+ events: [],
1098
+ storyFrameSizes: {},
1099
+ editor: { open: false, modified: false },
1100
+ regeneration: { status: 'idle' },
1101
+ };
1102
+
1103
+ function selectedComponent(): PreviewComponent | undefined {
1104
+ return state.selection.kind === 'component'
1105
+ ? componentById(state.selection.id)
1106
+ : undefined;
1107
+ }
1108
+
1109
+ function commit(changes: Partial<PreviewState>): void {
1110
+ state = withCode({ ...state, ...changes });
1111
+ persist();
1112
+ postHtml();
1113
+ postCode();
1114
+ for (const listener of listeners) listener(state);
1115
+ }
1116
+
1117
+ // --- the source ------------------------------------------------------------
1118
+ // The scripted targets' source is printed whenever what the panes show changes; the
1119
+ // html is asked of the server, or read from what the static build printed,
1120
+ // once per story, and a request for a story since replaced is dropped.
1121
+ let sourcedFrom:
1122
+ | (Pick<PreviewState, 'selection' | 'scenario' | 'props'> & {
1123
+ tree?: EditorState;
1124
+ })
1125
+ | undefined;
1126
+ let htmlKey: string | undefined;
1127
+ let htmlTimer: unknown = null;
1128
+ let htmlAbort: AbortController | undefined;
1129
+ let htmlSequence = 0;
1130
+
1131
+ const STATIC_EDITED_REASON =
1132
+ 'The static build prints html for the scenarios alone: there is none for changed props or edited content.';
1133
+
1134
+ function cancelHtml(): void {
1135
+ if (htmlTimer !== null) {
1136
+ timers.clearTimeout(htmlTimer);
1137
+ htmlTimer = null;
1138
+ }
1139
+ htmlAbort?.abort();
1140
+ htmlAbort = undefined;
1141
+ htmlSequence += 1;
1142
+ }
1143
+
1144
+ function htmlFrom(
1145
+ response: HtmlSourceResponse | undefined
1146
+ ): PreviewHtmlSource {
1147
+ if (response && 'markup' in response)
1148
+ return {
1149
+ status: 'ready',
1150
+ source: response.markup,
1151
+ ...(response.stylesheet === undefined
1152
+ ? {}
1153
+ : { stylesheet: response.stylesheet }),
1154
+ ...(response.warnings === undefined || response.warnings.length === 0
1155
+ ? {}
1156
+ : { warnings: response.warnings }),
1157
+ };
1158
+ if (response && 'missing' in response)
1159
+ return {
1160
+ status: 'unavailable',
1161
+ reason: `This story has no html: it holds ${response.missing.join('; ')}.`,
1162
+ };
1163
+ return {
1164
+ status: 'unavailable',
1165
+ reason: 'No html was printed for this scenario.',
1166
+ };
1167
+ }
1168
+
1169
+ /** Land an html answer, unless the story changed since it was asked. */
1170
+ function settleHtml(sequence: number, html: PreviewHtmlSource): void {
1171
+ if (sequence !== htmlSequence || !state.code) return;
1172
+ commit({ code: { ...state.code, html } });
1173
+ }
1174
+
1175
+ /** Start getting the html of a story, returning what the state shows meanwhile. */
1176
+ function requestHtml(
1177
+ component: PreviewComponent,
1178
+ request: HtmlSourceRequest,
1179
+ scenarioId: string,
1180
+ edited: boolean
1181
+ ): PreviewHtmlSource {
1182
+ cancelHtml();
1183
+ const sequence = htmlSequence;
1184
+ const htmlSource = controllerOptions.htmlSource;
1185
+ const scenarioHtmlSources = controllerOptions.scenarioHtmlSources;
1186
+ if (htmlSource) {
1187
+ htmlTimer = timers.setTimeout(() => {
1188
+ htmlTimer = null;
1189
+ const abort = new AbortController();
1190
+ htmlAbort = abort;
1191
+ htmlSource(request, abort.signal).then(
1192
+ (response) => settleHtml(sequence, htmlFrom(response)),
1193
+ (reason: unknown) => {
1194
+ if (abort.signal.aborted) return;
1195
+ settleHtml(sequence, {
1196
+ status: 'unavailable',
1197
+ reason: reason instanceof Error ? reason.message : String(reason),
1198
+ });
1199
+ }
1200
+ );
1201
+ }, REGENERATION_DEBOUNCE_MILLISECONDS);
1202
+ return { status: 'loading' };
1203
+ }
1204
+ if (scenarioHtmlSources) {
1205
+ if (edited)
1206
+ return { status: 'unavailable', reason: STATIC_EDITED_REASON };
1207
+ scenarioHtmlSources(component.id).then(
1208
+ (byScenario) => settleHtml(sequence, htmlFrom(byScenario[scenarioId])),
1209
+ (reason: unknown) =>
1210
+ settleHtml(sequence, {
1211
+ status: 'unavailable',
1212
+ reason: `No html was printed for this story: ${reason instanceof Error ? reason.message : String(reason)}`,
1213
+ })
1214
+ );
1215
+ return { status: 'loading' };
1216
+ }
1217
+ return {
1218
+ status: 'unavailable',
1219
+ reason: 'This preview has no way to print html.',
1220
+ };
1221
+ }
1222
+
1223
+ /** `next` with the code of what its panes show, printed afresh when that changed. */
1224
+ function withCode(next: PreviewState): PreviewState {
1225
+ const tree = next.editor.tree;
1226
+ if (
1227
+ sourcedFrom &&
1228
+ sourcedFrom.selection === next.selection &&
1229
+ sourcedFrom.scenario === next.scenario &&
1230
+ sourcedFrom.props === next.props &&
1231
+ sourcedFrom.tree === tree
1232
+ )
1233
+ return next;
1234
+ sourcedFrom = {
1235
+ selection: next.selection,
1236
+ scenario: next.scenario,
1237
+ props: next.props,
1238
+ tree,
1239
+ };
1240
+ const component =
1241
+ next.selection.kind === 'component'
1242
+ ? componentById(next.selection.id)
1243
+ : undefined;
1244
+ if (!component) {
1245
+ cancelHtml();
1246
+ htmlKey = undefined;
1247
+ return next.code === undefined ? next : { ...next, code: undefined };
1248
+ }
1249
+ const scenario = component.scenarios.find(
1250
+ (candidate) => candidate.id === next.scenario
1251
+ );
1252
+ const story = resolveSourceStory(component, {
1253
+ props: next.props,
1254
+ scenario,
1255
+ tree,
1256
+ });
1257
+ const printed = printSourceStory(catalog, story, component.targets);
1258
+ const previous = next.code;
1259
+ const targets =
1260
+ previous &&
1261
+ Object.keys(printed).length === Object.keys(previous.targets).length &&
1262
+ component.targets.every(
1263
+ (target) => printed[target] === previous.targets[target]
1264
+ )
1265
+ ? previous.targets
1266
+ : printed;
1267
+ // The html pane shows an unfilled default slot's placeholder, as the
1268
+ // scripted panes do, though the printed code leaves it out.
1269
+ const placeholder =
1270
+ !tree &&
1271
+ !story.children &&
1272
+ typeof next.props.children === 'string' &&
1273
+ isPlaceholder(component, next.props.children, { scenario })
1274
+ ? ({ kind: 'text', value: next.props.children } as const)
1275
+ : undefined;
1276
+ const shownChildren = story.children ?? placeholder;
1277
+ const request: HtmlSourceRequest = {
1278
+ rootComponent: story.component,
1279
+ props: story.props,
1280
+ ...(shownChildren || Object.keys(story.named).length > 0
1281
+ ? {
1282
+ slots: {
1283
+ ...(shownChildren ? { default: shownChildren } : {}),
1284
+ ...(Object.keys(story.named).length > 0
1285
+ ? { named: story.named }
1286
+ : {}),
1287
+ },
1288
+ }
1289
+ : {}),
1290
+ ...(story.around ? { around: story.around } : {}),
1291
+ };
1292
+ // The static build printed the scenarios as they are seeded.
1293
+ const edited =
1294
+ tree !== undefined ||
1295
+ stableStringify(next.props) !== stableStringify(seededProps);
1296
+ const key = stableStringify([component.id, next.scenario, edited, request]);
1297
+ let html = previous?.html;
1298
+ if (key !== htmlKey || !html) {
1299
+ htmlKey = key;
1300
+ html = requestHtml(component, request, next.scenario, edited);
1301
+ }
1302
+ if (previous && previous.targets === targets && previous.html === html)
1303
+ return next;
1304
+ return { ...next, code: { targets, html } };
1305
+ }
1306
+
1307
+ // --- persistence -----------------------------------------------------------
1308
+ // Off while the starting state is read back.
1309
+ let persisting = false;
1310
+ // Above zero while a shell's action runs. The address is the controller's to
1311
+ // write only from the first action on, so a page that is only opened, or a
1312
+ // shell's own page the controller has not been asked to change, keeps the
1313
+ // address it was opened with.
1314
+ let acting = 0;
1315
+ let ownsAddress = false;
1316
+ let lastStored: string | undefined;
1317
+
1318
+ /** Run `work` as a shell's action: a change it commits claims the address. */
1319
+ function action<Parameters extends unknown[], Result>(
1320
+ work: (...parameters: Parameters) => Result
1321
+ ): (...parameters: Parameters) => Result {
1322
+ return (...parameters) => {
1323
+ acting += 1;
1324
+ try {
1325
+ return work(...parameters);
1326
+ } finally {
1327
+ acting -= 1;
1328
+ }
1329
+ };
1330
+ }
1331
+
1332
+ /** The global values that differ from their defaults: what is remembered. */
1333
+ function changedGlobalValues(): Record<string, OptionValue> {
1334
+ const changed: Record<string, OptionValue> = {};
1335
+ for (const option of options) {
1336
+ const value = globalValues[option.id];
1337
+ if (
1338
+ option.scope === 'global' &&
1339
+ value !== undefined &&
1340
+ value !== option.default
1341
+ )
1342
+ changed[option.id] = value;
1343
+ }
1344
+ return changed;
1345
+ }
1346
+
1347
+ /** The option values the URL keeps: the changed globals, and the selected
1348
+ * story's own overrides that differ from its defaults. */
1349
+ function urlOptionValues(): Record<string, OptionValue> {
1350
+ const values = changedGlobalValues();
1351
+ const component = selectedComponent();
1352
+ if (!component) return values;
1353
+ const defaults = storyDefaultsFor(component);
1354
+ const unavailable = unavailableOptionsFor(component);
1355
+ for (const [id, value] of Object.entries(storyOverrides)) {
1356
+ if (!unavailable.includes(id) && value !== defaults[id])
1357
+ values[id] = value;
1358
+ }
1359
+ // A value the scenario sets that was changed back to its default since
1360
+ // goes in too, or opening the address would set the scenario's again.
1361
+ const merged = mergedOptions(component);
1362
+ for (const [id, value] of Object.entries(
1363
+ scenarioOptionValues(component, state.scenario)
1364
+ )) {
1365
+ if (merged[id] !== value) values[id] = merged[id];
1366
+ }
1367
+ return values;
1368
+ }
1369
+
1370
+ function persist(): void {
1371
+ if (!persisting) return;
1372
+ if (acting > 0) ownsAddress = true;
1373
+ if (ownsAddress) writeUrl();
1374
+ storeOptions();
1375
+ }
1376
+
1377
+ function storeOptions(): void {
1378
+ if (!storage) return;
1379
+ const changed = changedGlobalValues();
1380
+ const serialized = JSON.stringify(changed);
1381
+ if (serialized === lastStored) return;
1382
+ lastStored = serialized;
1383
+ writeStoredOptions(storage, storedOptionsKey, changed);
1384
+ }
1385
+
1386
+ function writeUrl(): void {
1387
+ const location = hostWindow?.location;
1388
+ const history = hostWindow?.history;
1389
+ if (!location || typeof history?.replaceState !== 'function') return;
1390
+ const component = selectedComponent();
1391
+ const scenario =
1392
+ component && state.scenario !== defaultScenarioId(component)
1393
+ ? state.scenario
1394
+ : undefined;
1395
+ const changed = component
1396
+ ? addressedProps(component, state.props)
1397
+ : undefined;
1398
+ const props =
1399
+ changed && Object.keys(changed).length > 0 ? changed : undefined;
1400
+ const targets = kitTargets.every((target) => visibleTargets.has(target))
1401
+ ? undefined
1402
+ : kitTargets.filter((target) => visibleTargets.has(target));
1403
+ const search = writeUrlState(location.search, {
1404
+ selection: state.selection,
1405
+ scenario,
1406
+ props,
1407
+ targets,
1408
+ width: state.paneWidth,
1409
+ options: urlOptionValues(),
1410
+ });
1411
+ if (search === location.search.replace(/^\?/, '')) return;
1412
+ try {
1413
+ // Replace, not push: a change of option is no step to go back to.
1414
+ history.replaceState(
1415
+ history.state,
1416
+ '',
1417
+ `${location.pathname}${search ? `?${search}` : ''}${location.hash}`
1418
+ );
1419
+ } catch {
1420
+ // A page that cannot rewrite its URL keeps the state in memory alone.
1421
+ }
1422
+ }
1423
+
1424
+ /** Whether the kit's landing entry is a view the catalog has. One it
1425
+ * lacks is told of once, in the console, and the first story shows. */
1426
+ function landsOnView(): boolean {
1427
+ const landing = catalog.landing;
1428
+ if (landing?.kind !== 'view') return false;
1429
+ if (catalog.views.some((view) => view.id === landing.id)) return true;
1430
+ console.warn(
1431
+ `[preview] preview.landing names view '${landing.id}', which the views module does not register; the preview opens on the first story. The views: ${
1432
+ catalog.views.map((view) => view.id).join(', ') || 'none'
1433
+ }.`
1434
+ );
1435
+ return false;
1436
+ }
1437
+
1438
+ /** Whether the kit's landing entry is a story the catalog has. */
1439
+ function landsOnComponent(): boolean {
1440
+ const landing = catalog.landing;
1441
+ return (
1442
+ landing?.kind === 'component' &&
1443
+ stories.some((component) => component.id === landing.id)
1444
+ );
1445
+ }
1446
+
1447
+ /** Start from what the URL and storage say: stored globals, then the URL's
1448
+ * values over them, the visible targets, its selection and scenario,
1449
+ * its story options and its props. Where the address names no view or
1450
+ * story, the kit's landing entry shows, else the first listed story. The
1451
+ * address itself is left as it is. */
1452
+ function restore(): void {
1453
+ const fromUrl = readUrlState(hostWindow?.location?.search ?? '');
1454
+ const globalOptions = options.filter((option) => option.scope === 'global');
1455
+ const stored = storage
1456
+ ? readStoredOptions(storage, storedOptionsKey, globalOptions)
1457
+ : {};
1458
+ lastStored = storage ? JSON.stringify(stored) : undefined;
1459
+ const fromUrlGlobals: Record<string, OptionValue> = {};
1460
+ for (const option of globalOptions) {
1461
+ const raw = fromUrl.options[option.id];
1462
+ const value =
1463
+ raw === undefined ? undefined : parseOptionValue(option, raw);
1464
+ if (value !== undefined) fromUrlGlobals[option.id] = value;
1465
+ }
1466
+ globalValues = { ...globalValues, ...stored, ...fromUrlGlobals };
1467
+
1468
+ const visible =
1469
+ fromUrl.targets === undefined
1470
+ ? undefined
1471
+ : parseTargets(fromUrl.targets, kitTargets);
1472
+ if (visible) {
1473
+ for (const target of kitTargets) {
1474
+ if (visible.includes(target)) visibleTargets.add(target);
1475
+ else visibleTargets.delete(target);
1476
+ }
1477
+ }
1478
+ const paneWidth =
1479
+ fromUrl.width === undefined ? undefined : parsePaneWidth(fromUrl.width);
1480
+ if (paneWidth !== undefined) state = { ...state, paneWidth };
1481
+
1482
+ if (
1483
+ fromUrl.view !== undefined &&
1484
+ catalog.views.some((view) => view.id === fromUrl.view)
1485
+ ) {
1486
+ selectView(fromUrl.view);
1487
+ } else if (
1488
+ fromUrl.component !== undefined &&
1489
+ stories.some((component) => component.id === fromUrl.component)
1490
+ ) {
1491
+ selectComponent(fromUrl.component);
1492
+ const component = selectedComponent()!;
1493
+ const scenario = fromUrl.scenario;
1494
+ const known = component.scenarios.find(
1495
+ (candidate) => candidate.id === scenario
1496
+ );
1497
+ // Empty renders through a regeneration, which a static build cannot do.
1498
+ if (
1499
+ scenario === '' ||
1500
+ (scenario !== undefined && known && (!known.empty || regeneration))
1501
+ )
1502
+ pickScenario(scenario);
1503
+ // The address's values win over those the scenario sets.
1504
+ globalValues = { ...globalValues, ...fromUrlGlobals };
1505
+ const unavailable = unavailableOptionsFor(component);
1506
+ for (const option of options) {
1507
+ const raw = fromUrl.options[option.id];
1508
+ if (
1509
+ option.scope !== 'story' ||
1510
+ raw === undefined ||
1511
+ unavailable.includes(option.id)
1512
+ )
1513
+ continue;
1514
+ const value = parseOptionValue(option, raw);
1515
+ if (value !== undefined)
1516
+ storyOverrides = { ...storyOverrides, [option.id]: value };
1517
+ }
1518
+ const props =
1519
+ fromUrl.props === undefined
1520
+ ? undefined
1521
+ : parseProps(
1522
+ fromUrl.props,
1523
+ addressableProps(component),
1524
+ // The address holds the props that differ from the
1525
+ // scenario's, which a story rendering from a tree (Empty)
1526
+ // seeds without its content.
1527
+ seededProps,
1528
+ seededProps,
1529
+ catalog.colorTokens
1530
+ );
1531
+ if (props) setProps(props);
1532
+ } else if (landsOnView()) {
1533
+ selectView(catalog.landing!.id);
1534
+ globalValues = { ...globalValues, ...fromUrlGlobals };
1535
+ } else if (landsOnComponent()) {
1536
+ // The kit's landing story, on its scenario or its baseline, with the
1537
+ // values that scenario sets, the address's winning.
1538
+ const landing = catalog.landing!;
1539
+ selectComponent(landing.id);
1540
+ const scenario =
1541
+ landing.kind === 'component' ? landing.scenario : undefined;
1542
+ const known = selectedComponent()!.scenarios.find(
1543
+ (candidate) => candidate.id === scenario
1544
+ );
1545
+ // Empty renders through a regeneration, which a static build cannot
1546
+ // do: the story opens on its baseline, and the console says why.
1547
+ if (known && (!known.empty || regeneration)) pickScenario(known.id);
1548
+ else if (known?.empty)
1549
+ console.warn(
1550
+ `[preview] preview.landing names the scenario '${known.id}' of '${landing.id}', which renders through the development server alone; the static build opens the story on its baseline.`
1551
+ );
1552
+ globalValues = { ...globalValues, ...fromUrlGlobals };
1553
+ } else if (first) {
1554
+ // The first story shows on its baseline scenario, with the values that
1555
+ // scenario sets, the address's winning.
1556
+ applyScenarioOptions(first, defaultScenarioId(first));
1557
+ globalValues = { ...globalValues, ...fromUrlGlobals };
1558
+ }
1559
+ state = withCode({
1560
+ ...state,
1561
+ options: mergedOptions(selectedComponent()),
1562
+ targets: targetsFor(selectedComponent() ?? first),
1563
+ });
1564
+ persisting = true;
1565
+ storeOptions();
1566
+ }
1567
+
1568
+ // --- the iframes -----------------------------------------------------------
1569
+ function postTo(frame: MountedFrame, message: unknown): void {
1570
+ frame.iframe.contentWindow?.postMessage(message, '*');
1571
+ }
1572
+
1573
+ /** The selection's set-story message: the story, its props, its events
1574
+ * and the two-way props a pane writes back itself, the scratch story's
1575
+ * those of the editor's root component. Each message but a pane's answer
1576
+ * to its ready (`fresh` false) is a new mount of the story, which the
1577
+ * panes tag their events with: an event of a story since replaced writes
1578
+ * nothing back. */
1579
+ function storyMessage(
1580
+ id: string,
1581
+ props: Record<string, unknown>,
1582
+ fresh = true
1583
+ ): ReturnType<typeof setStoryMessage> {
1584
+ if (fresh) storyMount += 1;
1585
+ const component = storyComponent(id);
1586
+ return setStoryMessage(
1587
+ id,
1588
+ paneProps(props, component),
1589
+ component?.events,
1590
+ // Only a binding whose change prop is a recorded event: a pane writes
1591
+ // back only what it then reports, so the host can answer each write.
1592
+ component?.bindings.flatMap((binding) =>
1593
+ binding.changeProp === undefined ||
1594
+ !component.events.includes(binding.changeProp)
1595
+ ? []
1596
+ : [{ prop: binding.prop, changeProp: binding.changeProp }]
1597
+ ),
1598
+ storyMount
1599
+ );
1600
+ }
1601
+
1602
+ /** The id the panes mount the selected story by: its scenario's own
1603
+ * story when that has content of its own, else the selection's. */
1604
+ function selectionStoryId(): string {
1605
+ const component = selectedComponent();
1606
+ return component
1607
+ ? paneStoryId(component, state.scenario)
1608
+ : state.selection.id;
1609
+ }
1610
+
1611
+ /** The component the story `id` renders at its root: the story's own, or,
1612
+ * for the scratch story, the editor's root component. */
1613
+ function storyComponent(id: string): PreviewComponent | undefined {
1614
+ return id === SCRATCH_STORY_ID
1615
+ ? state.editor.tree
1616
+ ? componentByName(state.editor.tree.root)
1617
+ : undefined
1618
+ : (componentById(id) ?? scenarioStoryComponents.get(id));
1619
+ }
1620
+
1621
+ /** The panes' story's root component: the selection's, or the editor's
1622
+ * root while the scratch story shows. */
1623
+ function shownComponent(): PreviewComponent | undefined {
1624
+ if (state.selection.kind !== 'component') return undefined;
1625
+ return storyComponent(
1626
+ scratchActive ? SCRATCH_STORY_ID : state.selection.id
1627
+ );
1628
+ }
1629
+
1630
+ /**
1631
+ * Take the value a pane reports through a two-way prop's change callback
1632
+ * as the story's new prop, when the story gives the prop: a controlled
1633
+ * story then follows a click in any pane, every pane and the html with
1634
+ * it, as `setProps` does. A story that leaves the prop out lets the
1635
+ * component keep its own value, and an unchanged value changes nothing.
1636
+ */
1637
+ function writeBack(
1638
+ name: string,
1639
+ data: { value?: unknown; mount?: unknown }
1640
+ ): void {
1641
+ if (!('value' in data)) return;
1642
+ // An event the pane recorded under a story it has since been told to
1643
+ // replace.
1644
+ if (typeof data.mount === 'number' && data.mount !== storyMount) return;
1645
+ // Every two-way prop the story gives that reports through this change
1646
+ // prop and holds another value.
1647
+ const changed = (shownComponent()?.bindings ?? []).filter(
1648
+ (binding) =>
1649
+ binding.changeProp === name &&
1650
+ state.props[binding.prop] !== undefined &&
1651
+ stableStringify(state.props[binding.prop]) !==
1652
+ stableStringify(data.value)
1653
+ );
1654
+ if (changed.length === 0) return;
1655
+ // A change the story's reader made: it claims the address, as a shell's
1656
+ // action does.
1657
+ action(setProps)({
1658
+ ...state.props,
1659
+ ...Object.fromEntries(
1660
+ changed.map((binding) => [binding.prop, data.value])
1661
+ ),
1662
+ });
1663
+ }
1664
+
1665
+ /** Post to the selection's scripted panes; a detached pane keeps its own
1666
+ * story, and the html pane shows the markup `postHtml` sends it. */
1667
+ function broadcast(message: unknown): void {
1668
+ for (const frame of frames)
1669
+ if (!frame.story && frame.target !== 'html') postTo(frame, message);
1670
+ }
1671
+
1672
+ /** Post the story's new root props to the selection's scripted panes,
1673
+ * each message naming the newest of that pane's own write-backs the
1674
+ * host has taken, so a pane keeps a later write of its own over them. */
1675
+ function broadcastProps(props: Record<string, unknown>): void {
1676
+ const shown = paneProps(props, shownComponent());
1677
+ for (const frame of frames)
1678
+ if (!frame.story && frame.target !== 'html')
1679
+ postTo(frame, propsMessage(shown, frame.answered ?? 0));
1680
+ }
1681
+
1682
+ /** Post option values to the selection's panes, the html pane's included. */
1683
+ function broadcastGlobals(values: Record<string, OptionValue>): void {
1684
+ for (const frame of frames)
1685
+ if (!frame.story) postTo(frame, globalsMessage(kitValues(values)));
1686
+ for (const frame of codeFrames)
1687
+ if (frame.ready)
1688
+ frame.iframe.contentWindow?.postMessage(
1689
+ globalsMessage(codeValues(values)),
1690
+ '*'
1691
+ );
1692
+ }
1693
+
1694
+ /** The html pane's message for the html the state holds. */
1695
+ function htmlPaneMessage(): ReturnType<typeof markupMessage> {
1696
+ const html = state.code?.html;
1697
+ if (html?.status === 'ready')
1698
+ return markupMessage(
1699
+ html.stylesheet === undefined
1700
+ ? { markup: html.source }
1701
+ : { markup: html.source, stylesheet: html.stylesheet }
1702
+ );
1703
+ if (html?.status === 'unavailable')
1704
+ return markupMessage({ reason: html.reason });
1705
+ return markupMessage({});
1706
+ }
1707
+
1708
+ // The html the html panes were last sent, so each change is sent once.
1709
+ let postedHtml: PreviewHtmlSource | undefined;
1710
+
1711
+ /** Send the html panes the state's html when it changed since last sent. */
1712
+ function postHtml(): void {
1713
+ const html = state.code?.html;
1714
+ if (html === postedHtml) return;
1715
+ postedHtml = html;
1716
+ if (!html) return;
1717
+ // A pane still loading gets the html when it answers the handshake.
1718
+ const message = htmlPaneMessage();
1719
+ for (const frame of frames)
1720
+ if (frame.target === 'html' && readinessOf(frame) === 'ready')
1721
+ postTo(frame, message);
1722
+ }
1723
+
1724
+ // The events recorded so far, numbering each.
1725
+ let eventCount = 0;
1726
+
1727
+ // --- the code blocks ----------------------------------------------------------
1728
+ // The kit's code block, one iframe per code pane, kept apart from the story
1729
+ // panes: it reaches no readiness or size in the state.
1730
+ interface CodeFrame {
1731
+ target: PreviewTarget;
1732
+ iframe: HTMLIFrameElement;
1733
+ ready: boolean;
1734
+ }
1735
+ const codeFrames: CodeFrame[] = [];
1736
+
1737
+ /** Hide an iframe until it shows its first content: a frame's page would
1738
+ * otherwise show the browser's blank canvas, then whatever it paints on the
1739
+ * way. Hidden, it leaves the shell's ground (and any loading note the shell
1740
+ * draws beneath it) in view. */
1741
+ function conceal(iframe: HTMLIFrameElement): void {
1742
+ iframe.style.visibility = 'hidden';
1743
+ iframe.removeAttribute(PREVIEW_FRAME_SHOWN_ATTRIBUTE);
1744
+ }
1745
+
1746
+ /** Show an iframe whose first content has painted, marked for a shell's CSS. */
1747
+ function reveal(iframe: HTMLIFrameElement): void {
1748
+ iframe.style.removeProperty('visibility');
1749
+ iframe.setAttribute(PREVIEW_FRAME_SHOWN_ATTRIBUTE, '');
1750
+ }
1751
+ // Whether the shell wraps its code panes, which the code blocks follow.
1752
+ let codeWrap = false;
1753
+
1754
+ /** What a code block shows for `target`: its source, or why it has none;
1755
+ * with neither, the source is being printed. */
1756
+ function codeContent(target: PreviewTarget): CodeBlockContent {
1757
+ const language = CODE_LANGUAGES[target];
1758
+ const wrap = codeWrap;
1759
+ const code = state.code;
1760
+ if (target === 'html') {
1761
+ const html = code?.html;
1762
+ if (html?.status === 'ready')
1763
+ return { target, language, wrap, source: html.source };
1764
+ if (html?.status === 'unavailable')
1765
+ return { target, language, wrap, reason: html.reason };
1766
+ return { target, language, wrap };
1767
+ }
1768
+ const printed = code?.targets[target];
1769
+ return printed === undefined
1770
+ ? {
1771
+ target,
1772
+ language,
1773
+ wrap,
1774
+ reason: `The story does not render in ${target}.`,
1775
+ }
1776
+ : { target, language, wrap, source: printed };
1777
+ }
1778
+
1779
+ // The source the code blocks were last sent, so each change is sent once.
1780
+ let postedCode: PreviewCode | undefined;
1781
+
1782
+ /** Send each code block its target's source when it changed since last
1783
+ * sent, or every block its content when `force`d. */
1784
+ function postCode(force = false): void {
1785
+ if (!force && state.code === postedCode) return;
1786
+ postedCode = state.code;
1787
+ for (const frame of codeFrames)
1788
+ if (frame.ready)
1789
+ frame.iframe.contentWindow?.postMessage(
1790
+ codeMessage(codeContent(frame.target)),
1791
+ '*'
1792
+ );
1793
+ }
1794
+
1795
+ function mountCode(element: HTMLElement, target: PreviewTarget): () => void {
1796
+ const iframe = element.ownerDocument.createElement('iframe');
1797
+ iframe.setAttribute('title', `${target} code`);
1798
+ iframe.setAttribute(
1799
+ 'src',
1800
+ codeFrameUrl(target, catalog.codeBlock?.framework, baseUrl)
1801
+ );
1802
+ conceal(iframe);
1803
+ element.appendChild(iframe);
1804
+ const frame: CodeFrame = { target, iframe, ready: false };
1805
+ codeFrames.push(frame);
1806
+ return () => {
1807
+ const index = codeFrames.indexOf(frame);
1808
+ if (index === -1) return;
1809
+ codeFrames.splice(index, 1);
1810
+ iframe.remove();
1811
+ };
1812
+ }
1813
+
1814
+ /** Bring each detached pane up to the current global values. */
1815
+ function postDetachedGlobals(): void {
1816
+ for (const frame of frames) {
1817
+ if (frame.story)
1818
+ postTo(
1819
+ frame,
1820
+ globalsMessage(
1821
+ detachedOptions(frame.story.component, frame.story.options)
1822
+ )
1823
+ );
1824
+ }
1825
+ }
1826
+
1827
+ function readinessOf(frame: MountedFrame): PreviewFrameReadiness | undefined {
1828
+ return frame.story
1829
+ ? state.storyFrames[frame.story.key]
1830
+ : state.frames[frame.target];
1831
+ }
1832
+
1833
+ /** The state change that sets a frame's readiness, or clears it. */
1834
+ function withReadiness(
1835
+ frame: MountedFrame,
1836
+ readiness: PreviewFrameReadiness | undefined
1837
+ ): Partial<PreviewState> {
1838
+ if (frame.story) {
1839
+ const storyFrames = { ...state.storyFrames };
1840
+ if (readiness) storyFrames[frame.story.key] = readiness;
1841
+ else delete storyFrames[frame.story.key];
1842
+ return { storyFrames };
1843
+ }
1844
+ const next = { ...state.frames };
1845
+ if (readiness) next[frame.target] = readiness;
1846
+ else delete next[frame.target];
1847
+ return { frames: next };
1848
+ }
1849
+
1850
+ function sizeOf(frame: MountedFrame): PreviewContentSize | undefined {
1851
+ return frame.story
1852
+ ? state.storyFrameSizes[frame.story.key]
1853
+ : state.frameSizes[frame.target];
1854
+ }
1855
+
1856
+ /** The state change that sets the size of what a frame shows, or clears it. */
1857
+ function withSize(
1858
+ frame: MountedFrame,
1859
+ size: PreviewContentSize | undefined
1860
+ ): Partial<PreviewState> {
1861
+ if (frame.story) {
1862
+ const storyFrameSizes = { ...state.storyFrameSizes };
1863
+ if (size) storyFrameSizes[frame.story.key] = size;
1864
+ else delete storyFrameSizes[frame.story.key];
1865
+ return { storyFrameSizes };
1866
+ }
1867
+ const frameSizes = { ...state.frameSizes };
1868
+ if (size) frameSizes[frame.target] = size;
1869
+ else delete frameSizes[frame.target];
1870
+ return { frameSizes };
1871
+ }
1872
+
1873
+ /** The state change that sets what a selection's pane threw, or clears it. */
1874
+ function withError(
1875
+ target: PreviewFrameTarget,
1876
+ message: string | undefined
1877
+ ): Partial<PreviewState> {
1878
+ const frameErrors = { ...state.frameErrors };
1879
+ if (message === undefined) delete frameErrors[target];
1880
+ else frameErrors[target] = message;
1881
+ return { frameErrors };
1882
+ }
1883
+
1884
+ /** Whether two frames report their readiness under the same key. */
1885
+ function sameReadinessKey(a: MountedFrame, b: MountedFrame): boolean {
1886
+ return a.story
1887
+ ? b.story?.key === a.story.key
1888
+ : !b.story && b.target === a.target;
1889
+ }
1890
+
1891
+ function mountFrame(
1892
+ element: HTMLElement,
1893
+ description: Omit<MountedFrame, 'iframe'>,
1894
+ title: string,
1895
+ source: string
1896
+ ): () => void {
1897
+ const iframe = element.ownerDocument.createElement('iframe');
1898
+ iframe.setAttribute('title', title);
1899
+ iframe.setAttribute('src', source);
1900
+ conceal(iframe);
1901
+ element.appendChild(iframe);
1902
+ const frame: MountedFrame = { ...description, iframe };
1903
+ frames.push(frame);
1904
+ // The new readiness is reported after the shell's mount call returns, so
1905
+ // no subscriber runs inside it: a shell that mounts in an effect reading
1906
+ // the state would otherwise see that state change within its own effect.
1907
+ // A pane unmounted before then is never reported.
1908
+ queueMicrotask(() => {
1909
+ if (!frames.includes(frame) || readinessOf(frame) === 'loading') return;
1910
+ commit(withReadiness(frame, 'loading'));
1911
+ });
1912
+ return () => {
1913
+ const index = frames.indexOf(frame);
1914
+ if (index === -1) return;
1915
+ frames.splice(index, 1);
1916
+ iframe.remove();
1917
+ if (
1918
+ frames.some((candidate) => sameReadinessKey(frame, candidate)) ||
1919
+ (readinessOf(frame) === undefined &&
1920
+ sizeOf(frame) === undefined &&
1921
+ (frame.story || state.frameErrors[frame.target] === undefined))
1922
+ )
1923
+ return;
1924
+ commit({
1925
+ ...withReadiness(frame, undefined),
1926
+ ...withSize(frame, undefined),
1927
+ ...(frame.story ? {} : withError(frame.target, undefined)),
1928
+ });
1929
+ };
1930
+ }
1931
+
1932
+ function mountStory(
1933
+ element: HTMLElement,
1934
+ target: PreviewScriptedTarget,
1935
+ story: PreviewStoryReference
1936
+ ): () => void {
1937
+ const component = stories.find(
1938
+ (candidate) => candidate.id === story.component
1939
+ );
1940
+ if (!component)
1941
+ throw new Error(
1942
+ `The catalog has no story "${story.component}" to mount: a detached pane takes the id of a standalone component.`
1943
+ );
1944
+ if (!component.targets.includes(target))
1945
+ throw new Error(
1946
+ `The story "${component.id}" does not render in ${target}: its targets are ${component.targets.join(', ')}.`
1947
+ );
1948
+ const scenarioId = story.scenario ?? defaultScenarioId(component);
1949
+ const scenario = component.scenarios.find(
1950
+ (candidate) => candidate.id === scenarioId
1951
+ );
1952
+ if (scenarioId !== '' && !scenario)
1953
+ throw new Error(
1954
+ `The story "${component.id}" has no scenario "${scenarioId}".`
1955
+ );
1956
+ if (scenario?.empty)
1957
+ throw new Error(
1958
+ `The Empty scenario of "${component.id}" renders through the selection's regenerated story and cannot be mounted apart from it: leave out the scenarios marked empty.`
1959
+ );
1960
+ const props = {
1961
+ ...seedProps(component, scenarioId),
1962
+ ...withoutUndefined(story.props ?? {}),
1963
+ };
1964
+ return mountFrame(
1965
+ element,
1966
+ {
1967
+ target: target,
1968
+ story: {
1969
+ key: storyFrameKey(target, story),
1970
+ component,
1971
+ id: paneStoryId(component, scenarioId),
1972
+ props,
1973
+ options: scenarioOptionValues(component, scenarioId),
1974
+ },
1975
+ },
1976
+ `${component.shortTitle}${scenario ? `: ${scenario.label}` : ''} (${target})`,
1977
+ componentFrameUrl(target, reloadCount, baseUrl)
1978
+ );
1979
+ }
1980
+
1981
+ function reloadPanes(): void {
1982
+ reloadCount += 1;
1983
+ const next = { ...state.frames };
1984
+ for (const frame of frames) {
1985
+ // A view, the html pane and a detached pane show no scratch story.
1986
+ if (frame.target === 'view' || frame.target === 'html' || frame.story)
1987
+ continue;
1988
+ conceal(frame.iframe);
1989
+ frame.iframe.setAttribute(
1990
+ 'src',
1991
+ componentFrameUrl(frame.target, reloadCount, baseUrl)
1992
+ );
1993
+ next[frame.target] = 'loading';
1994
+ }
1995
+ commit({ frames: next });
1996
+ }
1997
+
1998
+ function answerReady(frame: MountedFrame): void {
1999
+ // A pane that announces ready again was reloaded, and counts its
2000
+ // writes from zero.
2001
+ frame.answered = 0;
2002
+ if (frame.story) {
2003
+ postTo(
2004
+ frame,
2005
+ setStoryMessage(
2006
+ frame.story.id,
2007
+ paneProps(frame.story.props, frame.story.component)
2008
+ )
2009
+ );
2010
+ postTo(
2011
+ frame,
2012
+ globalsMessage(
2013
+ detachedOptions(frame.story.component, frame.story.options)
2014
+ )
2015
+ );
2016
+ postTo(frame, catalogMessage(catalogPayload));
2017
+ return;
2018
+ }
2019
+ if (frame.target === 'html') {
2020
+ postTo(frame, globalsMessage(kitValues(state.options)));
2021
+ postTo(frame, htmlPaneMessage());
2022
+ return;
2023
+ }
2024
+ // The scratch story holds the root's text as content, not as a prop.
2025
+ const tree = state.editor.tree;
2026
+ if (scratchActive && tree)
2027
+ postTo(frame, storyMessage(SCRATCH_STORY_ID, tree.props, false));
2028
+ else postTo(frame, storyMessage(selectionStoryId(), state.props, false));
2029
+ postTo(frame, globalsMessage(kitValues(state.options)));
2030
+ postTo(frame, catalogMessage(catalogPayload));
2031
+ }
2032
+
2033
+ function onMessage(event: MessageEvent): void {
2034
+ const data = event.data;
2035
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.shown)) {
2036
+ const shown = [...frames, ...codeFrames].find(
2037
+ (candidate) => candidate.iframe.contentWindow === event.source
2038
+ );
2039
+ if (shown) reveal(shown.iframe);
2040
+ return;
2041
+ }
2042
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.ready)) {
2043
+ const code = codeFrames.find(
2044
+ (candidate) => candidate.iframe.contentWindow === event.source
2045
+ );
2046
+ if (code) {
2047
+ code.ready = true;
2048
+ const codeWindow = code.iframe.contentWindow;
2049
+ codeWindow?.postMessage(globalsMessage(codeValues(state.options)), '*');
2050
+ codeWindow?.postMessage(codeMessage(codeContent(code.target)), '*');
2051
+ return;
2052
+ }
2053
+ const frame = frames.find(
2054
+ (candidate) => candidate.iframe.contentWindow === event.source
2055
+ );
2056
+ if (!frame) return;
2057
+ answerReady(frame);
2058
+ commit(withReadiness(frame, 'ready'));
2059
+ return;
2060
+ }
2061
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.select)) {
2062
+ // Only the view pane the controller mounted may select a story: a
2063
+ // component pane, the selection's or a detached one, never asks.
2064
+ const frame = frames.find(
2065
+ (candidate) =>
2066
+ candidate.target === 'view' &&
2067
+ candidate.iframe.contentWindow === event.source
2068
+ );
2069
+ if (!frame || typeof data.id !== 'string') return;
2070
+ if (!stories.some((component) => component.id === data.id)) return;
2071
+ action(selectComponent)(
2072
+ data.id,
2073
+ isRecord(data.props) ? data.props : undefined
2074
+ );
2075
+ return;
2076
+ }
2077
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.event)) {
2078
+ // The selection's scripted panes record their story's events; a
2079
+ // detached pane's stay its own.
2080
+ const frame = frames.find(
2081
+ (candidate) =>
2082
+ !candidate.story && candidate.iframe.contentWindow === event.source
2083
+ );
2084
+ if (!frame || typeof data.name !== 'string') return;
2085
+ eventCount += 1;
2086
+ const recorded: PreviewEvent = {
2087
+ id: eventCount,
2088
+ target: frame.target,
2089
+ name: data.name,
2090
+ payload: typeof data.payload === 'string' ? data.payload : '',
2091
+ time: Date.now(),
2092
+ };
2093
+ commit({ events: [...state.events, recorded].slice(-EVENT_LOG_LENGTH) });
2094
+ // The write the event reports is the host's from here on, whether or
2095
+ // not it changes the story: the next props to the pane answer it.
2096
+ if (typeof data.write === 'number' && data.write > (frame.answered ?? 0))
2097
+ frame.answered = data.write;
2098
+ writeBack(data.name, data);
2099
+ return;
2100
+ }
2101
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.renderError)) {
2102
+ // A detached story pane's error stays its own; the selection's panes
2103
+ // report theirs.
2104
+ const frame = frames.find(
2105
+ (candidate) =>
2106
+ !candidate.story && candidate.iframe.contentWindow === event.source
2107
+ );
2108
+ if (!frame) return;
2109
+ const message =
2110
+ typeof data.message === 'string' ? data.message : undefined;
2111
+ if (state.frameErrors[frame.target] === message) return;
2112
+ commit(withError(frame.target, message));
2113
+ return;
2114
+ }
2115
+ if (isPreviewMessage(data, PREVIEW_MESSAGE.contentSize)) {
2116
+ const frame = frames.find(
2117
+ (candidate) => candidate.iframe.contentWindow === event.source
2118
+ );
2119
+ const size = readContentSize(data);
2120
+ if (!frame || !size) return;
2121
+ const current = sizeOf(frame);
2122
+ if (current?.width === size.width && current.height === size.height)
2123
+ return;
2124
+ commit(withSize(frame, size));
2125
+ }
2126
+ }
2127
+ hostWindow?.addEventListener('message', onMessage);
2128
+
2129
+ // --- regeneration ----------------------------------------------------------
2130
+ function dropPendingRegeneration(): PreviewRegenerationStatus {
2131
+ if (regenerationTimer !== null) {
2132
+ timers.clearTimeout(regenerationTimer);
2133
+ regenerationTimer = null;
2134
+ }
2135
+ regenerationSequence += 1;
2136
+ return state.regeneration.status === 'pending'
2137
+ ? { status: 'idle' }
2138
+ : state.regeneration;
2139
+ }
2140
+
2141
+ /** Regenerate `tree` once edits pause. The returned status is the one to commit. */
2142
+ function scheduleRegeneration(tree: EditorState): PreviewRegenerationStatus {
2143
+ dropPendingRegeneration();
2144
+ if (!regeneration) {
2145
+ return {
2146
+ status: 'error',
2147
+ message:
2148
+ 'This preview cannot regenerate a story: it has no regeneration endpoint.',
2149
+ };
2150
+ }
2151
+ const generation = regenerationSequence;
2152
+ regenerationTimer = timers.setTimeout(() => {
2153
+ regenerationTimer = null;
2154
+ // The tree as it stands when the edits pause: the root's props may have
2155
+ // changed live since the last structural edit.
2156
+ const current = state.editor.tree ?? tree;
2157
+ // The content beside the root is the story's as the catalog has it now,
2158
+ // which no edit reaches.
2159
+ const around = componentById(state.selection.id)?.around;
2160
+ regeneration({
2161
+ ...toPayload(current),
2162
+ ...(around ? { around } : {}),
2163
+ }).then(
2164
+ () => {
2165
+ if (generation !== regenerationSequence) return;
2166
+ reloadPanes();
2167
+ commit({ regeneration: { status: 'idle' } });
2168
+ },
2169
+ (reason: unknown) => {
2170
+ if (generation !== regenerationSequence) return;
2171
+ const message =
2172
+ reason instanceof Error ? reason.message : String(reason);
2173
+ commit({ regeneration: { status: 'error', message } });
2174
+ }
2175
+ );
2176
+ }, REGENERATION_DEBOUNCE_MILLISECONDS);
2177
+ return { status: 'pending' };
2178
+ }
2179
+
2180
+ // --- selection -------------------------------------------------------------
2181
+ function selectComponent(id: string, props?: Record<string, unknown>): void {
2182
+ const component = stories.find((candidate) => candidate.id === id);
2183
+ if (!component) return;
2184
+ const regenerationStatus = dropPendingRegeneration();
2185
+ scratchActive = false;
2186
+ const baseline = props
2187
+ ? undefined
2188
+ : component.scenarios.find((scenario) => scenario.default);
2189
+ seededProps = seedProps(component, baseline?.id ?? '');
2190
+ const nextProps = { ...seededProps, ...(props ?? {}) };
2191
+ broadcast(
2192
+ storyMessage(paneStoryId(component, baseline?.id ?? ''), nextProps)
2193
+ );
2194
+ storyOverrides = {};
2195
+ const { globalChanged } = baseline
2196
+ ? applyScenarioOptions(component, baseline.id)
2197
+ : { globalChanged: false };
2198
+ const merged = mergedOptions(component);
2199
+ broadcastGlobals(merged);
2200
+ if (globalChanged) postDetachedGlobals();
2201
+ const targets = targetsFor(component);
2202
+ commit({
2203
+ selection: { kind: 'component', id },
2204
+ events: [],
2205
+ scenario: baseline?.id ?? '',
2206
+ props: nextProps,
2207
+ options: merged,
2208
+ unavailableOptions: unavailableOptionsFor(component),
2209
+ targets: sameTargets(targets.supported, state.targets.supported)
2210
+ ? state.targets
2211
+ : targets,
2212
+ editor: { open: false, modified: false },
2213
+ regeneration: regenerationStatus,
2214
+ });
2215
+ }
2216
+
2217
+ function selectView(id: string): void {
2218
+ if (!catalog.views.some((view) => view.id === id)) return;
2219
+ const regenerationStatus = dropPendingRegeneration();
2220
+ scratchActive = false;
2221
+ commit({
2222
+ selection: { kind: 'view', id },
2223
+ events: [],
2224
+ options: mergedOptions(undefined),
2225
+ // A view has no story to apply a story option to.
2226
+ unavailableOptions: storyOptionIds,
2227
+ editor: { open: false, modified: false },
2228
+ regeneration: regenerationStatus,
2229
+ });
2230
+ }
2231
+
2232
+ function pickScenario(id: string): void {
2233
+ const component = selectedComponent();
2234
+ if (!component) return;
2235
+ const scenario = id
2236
+ ? component.scenarios.find((candidate) => candidate.id === id)
2237
+ : undefined;
2238
+ // Empty renders through a regeneration, which a static build cannot do.
2239
+ if (scenario?.empty && !regeneration) return;
2240
+ if (scenario?.empty) {
2241
+ // Empty is content the real story cannot show, so it renders through the
2242
+ // regenerated scratch story: the story's props, no slot content.
2243
+ const tree: EditorState = {
2244
+ root: component.name,
2245
+ props: withoutContent(seedProps(component, id)),
2246
+ slots: {},
2247
+ };
2248
+ scratchActive = true;
2249
+ seededProps = tree.props;
2250
+ seededSlots = slotsKey(tree.slots);
2251
+ broadcast(storyMessage(SCRATCH_STORY_ID, cleanRecord(tree.props)));
2252
+ const regenerationStatus = scheduleRegeneration(tree);
2253
+ commit({
2254
+ scenario: id,
2255
+ props: tree.props,
2256
+ editor: { open: state.editor.open, tree, modified: false },
2257
+ regeneration: regenerationStatus,
2258
+ });
2259
+ return;
2260
+ }
2261
+ const regenerationStatus = dropPendingRegeneration();
2262
+ scratchActive = false;
2263
+ const props = seedProps(component, id);
2264
+ seededProps = props;
2265
+ broadcast(storyMessage(paneStoryId(component, id), props));
2266
+ const carried = applyScenarioOptions(component, id);
2267
+ const merged = carried.changed ? mergedOptions(component) : state.options;
2268
+ if (carried.changed) broadcastGlobals(merged);
2269
+ if (carried.globalChanged) postDetachedGlobals();
2270
+ let tree: EditorState | undefined;
2271
+ if (state.editor.open) {
2272
+ // The props read from the tree, as `open` seeds it.
2273
+ tree = seedRoot(onScenario(component, id), props);
2274
+ ensureIds(tree.slots);
2275
+ seededProps = treeProps(tree);
2276
+ seededSlots = slotsKey(tree.slots);
2277
+ }
2278
+ commit({
2279
+ scenario: id,
2280
+ props: seededProps,
2281
+ options: merged,
2282
+ editor: {
2283
+ open: state.editor.open,
2284
+ ...(tree ? { tree } : {}),
2285
+ modified: false,
2286
+ },
2287
+ regeneration: regenerationStatus,
2288
+ });
2289
+ }
2290
+
2291
+ function setOption(id: string, value: OptionValue): void {
2292
+ const descriptor = options.find((candidate) => candidate.id === id);
2293
+ if (descriptor?.scope === 'story')
2294
+ storyOverrides = { ...storyOverrides, [id]: value };
2295
+ else globalValues = { ...globalValues, [id]: value };
2296
+ const merged = mergedOptions(selectedComponent());
2297
+ // A shell option's value never reaches the iframes, so they are told
2298
+ // nothing of it.
2299
+ if (descriptor?.origin !== 'shell') broadcastGlobals(merged);
2300
+ if (descriptor?.scope === 'global' && descriptor.origin === 'kit')
2301
+ postDetachedGlobals();
2302
+ commit({ options: merged });
2303
+ }
2304
+
2305
+ function setTargetVisible(target: PreviewTarget, visible: boolean): void {
2306
+ if (visible) visibleTargets.add(target);
2307
+ else visibleTargets.delete(target);
2308
+ commit({ targets: targetsFor(selectedComponent()) });
2309
+ }
2310
+
2311
+ function setPaneWidth(width: number | undefined): void {
2312
+ if (width !== undefined && !isPaneWidth(width)) return;
2313
+ if (width === state.paneWidth) return;
2314
+ const { paneWidth: _paneWidth, ...rest } = state;
2315
+ state = rest;
2316
+ commit(width === undefined ? {} : { paneWidth: width });
2317
+ }
2318
+
2319
+ function setProps(props: Record<string, unknown>): void {
2320
+ if (state.selection.kind !== 'component') return;
2321
+ const next = withoutUndefined(props);
2322
+ const tree = state.editor.tree;
2323
+ if (!tree) {
2324
+ // The root renders with its props as they are: they reach the panes live.
2325
+ broadcastProps(cleanRecord(next));
2326
+ commit({ props: next });
2327
+ return;
2328
+ }
2329
+ // With a tree, where the story draws its default slot from `children`,
2330
+ // `children` is the root's text: a new one replaces the root's default
2331
+ // slot with that text, and one left out removes a lone text. Content
2332
+ // other than a lone text carries no `children` and stays. A story with
2333
+ // default-slot content of its own takes no `children`.
2334
+ const { children, ...rootProps } = next;
2335
+ const text = typeof children === 'string' ? children : undefined;
2336
+ if (drawsChildren(tree) && text !== rootText(tree)) {
2337
+ const slots = { ...tree.slots };
2338
+ if (text === undefined) delete slots[DEFAULT_SLOT];
2339
+ else slots[DEFAULT_SLOT] = [{ kind: 'text', value: text }];
2340
+ editTree({ ...tree, props: rootProps, slots });
2341
+ return;
2342
+ }
2343
+ const nextTree = { ...tree, props: rootProps };
2344
+ const shown = treeProps(nextTree);
2345
+ // The root's props reach the panes live; the scratch story holds the
2346
+ // root's text as content, not as a prop.
2347
+ broadcastProps(cleanRecord(scratchActive ? rootProps : shown));
2348
+ commit({
2349
+ props: sameProps(shown),
2350
+ editor: { ...state.editor, tree: nextTree },
2351
+ });
2352
+ }
2353
+
2354
+ /** `props`, or the state's own when they are equal, so an unchanged field
2355
+ * keeps its reference. */
2356
+ function sameProps(props: Record<string, unknown>): Record<string, unknown> {
2357
+ return stableStringify(props) === stableStringify(state.props)
2358
+ ? state.props
2359
+ : props;
2360
+ }
2361
+
2362
+ // --- the editor ------------------------------------------------------------
2363
+ function open(): void {
2364
+ if (state.editor.open) return;
2365
+ const component = selectedComponent();
2366
+ if (!component) return;
2367
+ let tree = state.editor.tree;
2368
+ if (!tree) {
2369
+ // The root starts from the props the panes show, so a prop set before
2370
+ // the editor opened stays set; the address still measures them against
2371
+ // the scenario's.
2372
+ const shown = onScenario(component, state.scenario);
2373
+ tree = seedRoot(shown, state.props);
2374
+ ensureIds(tree.slots);
2375
+ seededProps = treeProps(
2376
+ seedRoot(shown, seedProps(component, state.scenario))
2377
+ );
2378
+ seededSlots = slotsKey(tree.slots);
2379
+ // The props now read from the tree, which shows what the panes show:
2380
+ // a string `children` stays as the root's text. Where the story's own
2381
+ // content stands in its default slot, the panes render that content
2382
+ // and not the `children` prop, so `props` leaves it out and nothing on
2383
+ // screen changes, with nothing to post.
2384
+ commit({
2385
+ props: sameProps(treeProps(tree)),
2386
+ editor: { open: true, tree, modified: false },
2387
+ });
2388
+ return;
2389
+ }
2390
+ commit({ editor: { ...state.editor, open: true, tree } });
2391
+ }
2392
+
2393
+ function close(): void {
2394
+ if (!state.editor.open) return;
2395
+ commit({ editor: { ...state.editor, open: false } });
2396
+ }
2397
+
2398
+ function createNode(request: PreviewNodeRequest): EditorNode {
2399
+ if (request.kind === 'text') return { kind: 'text', value: request.value };
2400
+ if (request.kind === 'html')
2401
+ return { kind: 'html', tag: request.tag, props: {}, slots: {} };
2402
+ const component = componentByName(request.name);
2403
+ if (!component)
2404
+ throw new Error(`The catalog has no component named "${request.name}".`);
2405
+ const baseline = component.scenarios.find((scenario) => scenario.default);
2406
+ const named = component.scenarios.filter(
2407
+ (scenario) => !scenario.empty && !scenario.default
2408
+ );
2409
+ // A component with nothing to strip and no scenario to pick has no choice to
2410
+ // make: it comes in with its defaults.
2411
+ const hasChoice =
2412
+ component.slots.default ||
2413
+ component.slots.named.length > 0 ||
2414
+ named.length > 0;
2415
+ const importAs = hasChoice ? (request.importAs ?? 'empty') : 'default';
2416
+ const withDefaults = (over?: Record<string, unknown>) => ({
2417
+ ...component.defaultProps,
2418
+ ...(over ?? {}),
2419
+ });
2420
+ if (importAs === 'empty') return seedComponentNode(component);
2421
+ // A scenario with content of its own comes in with that content.
2422
+ if (importAs === 'default')
2423
+ return seedComponentNode(
2424
+ onScenario(component, baseline?.id ?? ''),
2425
+ withDefaults(baseline?.props)
2426
+ );
2427
+ const scenario = named.find(
2428
+ (candidate) => candidate.id === importAs.scenario
2429
+ );
2430
+ return seedComponentNode(
2431
+ onScenario(component, scenario?.id ?? ''),
2432
+ withDefaults(scenario?.props)
2433
+ );
2434
+ }
2435
+
2436
+ /** Apply a change to the tree's slot content: the panes switch to the scratch
2437
+ * story, which regenerates once edits pause. An unchanged tree does nothing. */
2438
+ /**
2439
+ * Whether the slot `slot` of the node at `parent` takes `node`, moved from
2440
+ * `from` or newly inserted. A scoped fill fills the one slot it names, so
2441
+ * it moves only within its own list; a slot whose content must take its
2442
+ * scope takes nothing else.
2443
+ */
2444
+ function slotTakes(
2445
+ tree: EditorState,
2446
+ parent: Step[],
2447
+ slot: string,
2448
+ node: EditorNode,
2449
+ from?: Step[]
2450
+ ): boolean {
2451
+ if (node.kind === 'opaque' && node.fill.kind === 'scoped')
2452
+ return (
2453
+ from !== undefined &&
2454
+ pathKey(from.slice(0, -1)) === pathKey(parent) &&
2455
+ from[from.length - 1].slot === slot
2456
+ );
2457
+ const holder = parent.length === 0 ? tree : nodeAt(tree.slots, parent);
2458
+ if (!holder) return true;
2459
+ return (
2460
+ !nodeSlots(catalog, holder).includes(slot) ||
2461
+ nodeAddSlots(catalog, holder).includes(slot)
2462
+ );
2463
+ }
2464
+
2465
+ function editContent(
2466
+ transform: (slots: EditorState['slots']) => EditorState['slots']
2467
+ ): void {
2468
+ const tree = state.editor.tree;
2469
+ if (!tree) return;
2470
+ const slots = transform(tree.slots);
2471
+ if (slots === tree.slots) return;
2472
+ editTree({ ...tree, slots });
2473
+ }
2474
+
2475
+ /** Show `next`, whose content differs from the current tree's, through the
2476
+ * scratch story; the root's text, if it changed, reaches `props`. */
2477
+ function editTree(next: EditorState): void {
2478
+ ensureIds(next.slots);
2479
+ scratchActive = true;
2480
+ broadcast(storyMessage(SCRATCH_STORY_ID, cleanRecord(next.props)));
2481
+ const regenerationStatus = scheduleRegeneration(next);
2482
+ commit({
2483
+ props: sameProps(treeProps(next)),
2484
+ editor: {
2485
+ ...state.editor,
2486
+ tree: next,
2487
+ modified: slotsKey(next.slots) !== seededSlots,
2488
+ },
2489
+ regeneration: regenerationStatus,
2490
+ });
2491
+ }
2492
+
2493
+ function setNodeProps(path: Step[], props: Record<string, unknown>): void {
2494
+ if (path.length === 0) {
2495
+ // The root's text is content: its props leave it, and `children`, as it is.
2496
+ const tree = state.editor.tree;
2497
+ if (!tree) {
2498
+ setProps(props);
2499
+ return;
2500
+ }
2501
+ const { children: _children, ...rootProps } = props;
2502
+ const text = treeProps(tree).children;
2503
+ setProps(
2504
+ text === undefined ? rootProps : { ...rootProps, children: text }
2505
+ );
2506
+ return;
2507
+ }
2508
+ const nextProps = withoutUndefined(props);
2509
+ editContent((slots) =>
2510
+ updateAt(slots, path, (node) =>
2511
+ isContainer(node) ? { ...node, props: nextProps } : node
2512
+ )
2513
+ );
2514
+ }
2515
+
2516
+ function dispose(): void {
2517
+ hostWindow?.removeEventListener('message', onMessage);
2518
+ dropPendingRegeneration();
2519
+ cancelHtml();
2520
+ for (const frame of [...frames]) frame.iframe.remove();
2521
+ frames.length = 0;
2522
+ for (const frame of [...codeFrames]) frame.iframe.remove();
2523
+ codeFrames.length = 0;
2524
+ listeners.clear();
2525
+ }
2526
+
2527
+ restore();
2528
+
2529
+ return {
2530
+ apiVersion: 1,
2531
+ catalog,
2532
+ options,
2533
+ capabilities: { regeneration: regeneration !== undefined },
2534
+ getState: () => state,
2535
+ subscribe(listener) {
2536
+ listeners.add(listener);
2537
+ return () => {
2538
+ listeners.delete(listener);
2539
+ };
2540
+ },
2541
+ actions: {
2542
+ selectComponent: action(selectComponent),
2543
+ selectView: action(selectView),
2544
+ select: action((selection: PreviewSelection) => {
2545
+ if (selection.kind === 'view') selectView(selection.id);
2546
+ else selectComponent(selection.id);
2547
+ }),
2548
+ pickScenario: action(pickScenario),
2549
+ setOption: action(setOption),
2550
+ setTargetVisible: action(setTargetVisible),
2551
+ setPaneWidth: action(setPaneWidth),
2552
+ setProps: action(setProps),
2553
+ clearEvents: action(() => {
2554
+ if (state.events.length > 0) commit({ events: [] });
2555
+ }),
2556
+ },
2557
+ editor: {
2558
+ open: action(open),
2559
+ close: action(close),
2560
+ createNode,
2561
+ insert: action((target: PreviewInsertTarget, node: EditorNode) => {
2562
+ const tree = state.editor.tree;
2563
+ if (tree && !slotTakes(tree, target.parent, target.slot, node)) return;
2564
+ editContent((slots) =>
2565
+ insertAt(slots, target.parent, target.slot, node, target.index)
2566
+ );
2567
+ }),
2568
+ move: action((from: Step[], to: DropTarget) => {
2569
+ const tree = state.editor.tree;
2570
+ const node =
2571
+ tree && from.length > 0 ? nodeAt(tree.slots, from) : undefined;
2572
+ if (tree && node && !slotTakes(tree, to.parent, to.slot, node, from))
2573
+ return;
2574
+ editContent((slots) => moveNode(slots, from, to));
2575
+ }),
2576
+ moveBy: action((path: Step[], offset: number) => {
2577
+ if (path.length === 0) return;
2578
+ editContent((slots) => {
2579
+ const parent = path.slice(0, -1);
2580
+ const { slot, index } = path[path.length - 1];
2581
+ const holder =
2582
+ parent.length === 0 ? undefined : nodeAt(slots, parent);
2583
+ const list =
2584
+ holder === undefined
2585
+ ? slots[slot]
2586
+ : holder && isContainer(holder)
2587
+ ? holder.slots[slot]
2588
+ : undefined;
2589
+ if (!list || index >= list.length) return slots;
2590
+ const destination = Math.min(
2591
+ Math.max(index + Math.trunc(offset), 0),
2592
+ list.length - 1
2593
+ );
2594
+ if (destination === index) return slots;
2595
+ // `moveNode` counts the target index before the node is taken out.
2596
+ const before = destination > index ? destination + 1 : destination;
2597
+ return moveNode(slots, path, { parent, slot, index: before });
2598
+ });
2599
+ }),
2600
+ replace: action((path: Step[], node: EditorNode) => {
2601
+ if (path.length === 0) return;
2602
+ editContent((slots) => updateAt(slots, path, () => node));
2603
+ }),
2604
+ remove: action((path: Step[]) => {
2605
+ if (path.length === 0) return;
2606
+ editContent((slots) => removeAt(slots, path));
2607
+ }),
2608
+ setNodeProps: action(setNodeProps),
2609
+ setText: action((path: Step[], text: string) => {
2610
+ editContent((slots) =>
2611
+ updateAt(slots, path, (node) =>
2612
+ node.kind === 'text' ? { ...node, value: text } : node
2613
+ )
2614
+ );
2615
+ }),
2616
+ resolveDrop(drop) {
2617
+ const move = resolveSortableMove(
2618
+ drop.source,
2619
+ drop.target,
2620
+ drop.sameList,
2621
+ drop.fromIndex,
2622
+ drop.toIndex
2623
+ );
2624
+ return { from: move.from, to: move.target };
2625
+ },
2626
+ },
2627
+ frames: {
2628
+ mount: (element, target) =>
2629
+ mountFrame(
2630
+ element,
2631
+ { target },
2632
+ target,
2633
+ componentFrameUrl(target, reloadCount, baseUrl)
2634
+ ),
2635
+ mountView: (element, viewId) =>
2636
+ mountFrame(
2637
+ element,
2638
+ { target: 'view' },
2639
+ viewId,
2640
+ viewFrameUrl(
2641
+ viewId,
2642
+ catalog.views.find((view) => view.id === viewId)?.framework,
2643
+ baseUrl
2644
+ )
2645
+ ),
2646
+ mountStory,
2647
+ mountCode,
2648
+ wrapCode(wrap: boolean) {
2649
+ if (wrap === codeWrap) return;
2650
+ codeWrap = wrap;
2651
+ postCode(true);
2652
+ },
2653
+ reload: action((target: PreviewFrameTarget) => {
2654
+ const next = { ...state.frames };
2655
+ for (const frame of frames) {
2656
+ if (frame.story || frame.target !== target) continue;
2657
+ conceal(frame.iframe);
2658
+ frame.iframe.contentWindow?.location.reload();
2659
+ next[target] = 'loading';
2660
+ }
2661
+ commit({ frames: next });
2662
+ }),
2663
+ standaloneUrl: (target: PreviewScriptedTarget) => {
2664
+ // The scratch story holds the root's text as content, not as a prop.
2665
+ const tree = state.editor.tree;
2666
+ const scratch = scratchActive && tree !== undefined;
2667
+ return standaloneFrameUrl(
2668
+ target,
2669
+ {
2670
+ id: scratch ? SCRATCH_STORY_ID : selectionStoryId(),
2671
+ props: paneProps(
2672
+ scratch ? tree.props : state.props,
2673
+ scratch ? componentByName(tree.root) : selectedComponent()
2674
+ ),
2675
+ options: kitValues(state.options),
2676
+ },
2677
+ baseUrl
2678
+ );
2679
+ },
2680
+ },
2681
+ dispose,
2682
+ };
2683
+ }