@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
package/src/check.ts ADDED
@@ -0,0 +1,1062 @@
1
+ // The preview check: everything in a kit's story files and manual specs that the
2
+ // generator would otherwise take on trust - prop names, enum values, slot names,
3
+ // fill components, story files with no component - checked against what the
4
+ // components actually declare, plus scenario coverage of every enum value and
5
+ // boolean, the kit's preview options and the option values its scenarios
6
+ // set, the packages the charcell shell needs installed, and what the kit's
7
+ // own build reports for its components. Run as
8
+ // `scaffold-ui-kit-preview check` or from a kit's own test suite.
9
+
10
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
11
+ import { createRequire } from 'node:module';
12
+ import { join, relative, resolve, sep } from 'node:path';
13
+ import { pathToFileURL } from 'node:url';
14
+ import { REACT_ATTRIBUTE_NAMES } from '@js-template-engine/extension-react';
15
+ import type {
16
+ ConfigurationLookupOptions,
17
+ KitDiagnostics,
18
+ } from 'scaffold-ui-kit';
19
+ import {
20
+ loadPreviewContext,
21
+ type KitLayoutPackage,
22
+ type PreviewContext,
23
+ } from './context.js';
24
+ import { kitPaneWidths, loadKit } from './load.js';
25
+ import { landingProblems } from './landing.js';
26
+ import {
27
+ resolveStory,
28
+ generatedScenarioId,
29
+ generatesProp,
30
+ scenarioValues,
31
+ } from './model.js';
32
+ import { collectSlotComponents } from './lib/slot-fill.js';
33
+ import { isStructurallyOmitted } from './story.js';
34
+ import {
35
+ acceptsOptionValue,
36
+ optionProblems,
37
+ type PreviewOption,
38
+ } from './options.js';
39
+ import { isHtmlAttribute } from './lib/html-attributes.js';
40
+ import {
41
+ missingShellPeers,
42
+ missingShellPeersProblem,
43
+ resolvesFrom,
44
+ } from './shell-peers.js';
45
+ import { HOST_PAGE, loadShellPage } from './shell-page.js';
46
+ import { moduleOfSource, TARGET_TOKEN } from './lib/module-of-source.js';
47
+ import { parseType, unionMembers } from './lib/parse-type.js';
48
+ import type { LoadedComponent } from './load.js';
49
+ import type {
50
+ EnumeratedComponent,
51
+ JsonValue,
52
+ PreviewControlHints,
53
+ SlotFill,
54
+ SlotPlan,
55
+ ResolvedStory,
56
+ } from './lib/types.js';
57
+
58
+ export interface PreviewCheckOptions extends ConfigurationLookupOptions {
59
+ /** Report missing scenario coverage as errors, not warnings. */
60
+ strict?: boolean;
61
+ }
62
+
63
+ /** The check's findings, each a line naming its component. */
64
+ export interface PreviewCheckReport {
65
+ /** Story file or manual-spec mistakes, and what fails `scaffold-ui-kit
66
+ * build` for a template in the kit's `src/components/` (a line naming the
67
+ * template's path, as the build prints it); under `strict`, coverage
68
+ * gaps too. */
69
+ errors: string[];
70
+ /** What reading the kit configuration warns of, such as its old file
71
+ * name, which stays a warning under `strict`; an enum value or boolean
72
+ * no scenario sets, among the props generation
73
+ * covers: a curated scenario that takes a generated id replaces that
74
+ * scenario, and is named when it does not set the value (an error under
75
+ * `strict`); a scenario prop set to `undefined` that the story's starting
76
+ * value covers, which `null` would unset (an error under `strict`); and
77
+ * what `scaffold-ui-kit build` warns of for a template in
78
+ * `src/components/`, named by its path, which stays a warning under
79
+ * `strict`. The build's lines cover validating and
80
+ * rendering each template, by the kit's own installed scaffolder; a
81
+ * driver colliding with a generated file and the stylesheets compiled
82
+ * under `stylesheetLink: 'both'` are reported by the build alone. */
83
+ warnings: string[];
84
+ /** Props hidden from the controls because their type is not a control. */
85
+ hidden: string[];
86
+ /** Select and boolean props the story file's `generate` leaves out of scenario
87
+ * generation and coverage: still controls, but no scenario is expected. */
88
+ ungenerated: string[];
89
+ }
90
+
91
+ const STORY_FILE_SUFFIX = '.story.ts';
92
+
93
+ function format(value: JsonValue): string {
94
+ return typeof value === 'string' ? `'${value}'` : JSON.stringify(value);
95
+ }
96
+
97
+ /** The HTML spelling of each attribute name React spells its own way
98
+ * (`autoComplete` → `autocomplete`, `className` → `class`). */
99
+ const HTML_ATTRIBUTE_NAMES: ReadonlyMap<string, string> = new Map([
100
+ ['className', 'class'],
101
+ ...Object.entries(REACT_ATTRIBUTE_NAMES).map(
102
+ ([html, react]) => [react, html] as [string, string]
103
+ ),
104
+ ]);
105
+
106
+ /**
107
+ * Checks the components a fill tree composes against their declarations: an
108
+ * `element` fill's props against the component's props (its default slot's
109
+ * `children` and, where its root forwards them, native attributes among
110
+ * them) and its `slots` against the named slots it declares, at every depth.
111
+ * An unknown component is reported once, from the story's references.
112
+ */
113
+ function checkFillTree(
114
+ name: string,
115
+ where: string,
116
+ fill: SlotFill | undefined,
117
+ knownComponents: ReadonlyMap<string, EnumeratedComponent>,
118
+ errors: string[]
119
+ ): void {
120
+ if (!fill) return;
121
+ if (fill.kind === 'fragment') {
122
+ for (const child of fill.children)
123
+ checkFillTree(name, where, child, knownComponents, errors);
124
+ return;
125
+ }
126
+ if (fill.kind === 'html') {
127
+ checkFillTree(name, where, fill.children, knownComponents, errors);
128
+ return;
129
+ }
130
+ if (fill.kind !== 'element') return;
131
+ const composed = knownComponents.get(fill.component);
132
+ if (composed) {
133
+ const propNames = new Set([
134
+ ...Object.keys(composed.props),
135
+ ...(composed.hasDefaultSlot ? ['children'] : []),
136
+ ]);
137
+ const forwardsAttributes = Boolean(composed.passthrough || composed.rest);
138
+ for (const prop of Object.keys(fill.props ?? {})) {
139
+ if (
140
+ !propNames.has(prop) &&
141
+ !(forwardsAttributes && isHtmlAttribute(prop))
142
+ ) {
143
+ const html = HTML_ATTRIBUTE_NAMES.get(prop);
144
+ errors.push(
145
+ html !== undefined &&
146
+ (propNames.has(html) ||
147
+ (forwardsAttributes && isHtmlAttribute(html)))
148
+ ? `${name}: ${where} gives ${fill.component} unknown prop '${prop}'; a story spells native attributes as HTML does: '${html}'`
149
+ : `${name}: ${where} gives ${fill.component} unknown prop '${prop}'`
150
+ );
151
+ }
152
+ }
153
+ const slotNames = new Set([
154
+ ...composed.slotNames,
155
+ ...(composed.memberNames ?? []),
156
+ ]);
157
+ for (const slot of Object.keys(fill.slots ?? {})) {
158
+ if (!slotNames.has(slot))
159
+ errors.push(
160
+ `${name}: ${where} fills named slot '${slot}' of ${fill.component}, which it lacks`
161
+ );
162
+ }
163
+ }
164
+ checkFillTree(name, where, fill.children, knownComponents, errors);
165
+ for (const slotFill of Object.values(fill.slots ?? {}))
166
+ checkFillTree(name, where, slotFill, knownComponents, errors);
167
+ }
168
+
169
+ /** Checks one component's story file against its declared props and slots. */
170
+ function checkComponent(
171
+ { component, story }: LoadedComponent,
172
+ model: ResolvedStory,
173
+ knownComponents: ReadonlyMap<string, EnumeratedComponent>,
174
+ hints: PreviewControlHints,
175
+ errors: string[],
176
+ warnings: string[]
177
+ ): void {
178
+ if (!story) return;
179
+ const name = component.name;
180
+ const propNames = new Set(Object.keys(component.props));
181
+ // `children` is the default slot's text, which defaultProps and scenarios may set.
182
+ const valueNames = new Set([
183
+ ...propNames,
184
+ ...(component.hasDefaultSlot ? ['children'] : []),
185
+ ]);
186
+ const fixed = story.fixed ?? {};
187
+ // A `passthrough` root or a `rest` spread takes the consumer's native
188
+ // attributes beside the declared props.
189
+ const forwardsAttributes = Boolean(component.passthrough || component.rest);
190
+
191
+ for (const prop of Object.keys(story.controls ?? {})) {
192
+ if (!propNames.has(prop))
193
+ errors.push(`${name}: controls names unknown prop '${prop}'`);
194
+ }
195
+ // Every place a story file sets prop values, checked for names and enum values.
196
+ const valueSources: Array<{
197
+ where: string;
198
+ props: Record<string, JsonValue>;
199
+ }> = [
200
+ { where: 'defaultProps', props: story.defaultProps ?? {} },
201
+ { where: 'fixed', props: fixed },
202
+ ...(story.scenarios ?? []).map((scenario) => ({
203
+ where: `scenario '${scenario.id}'`,
204
+ props: scenario.props ?? {},
205
+ })),
206
+ ];
207
+ for (const { where, props } of valueSources) {
208
+ for (const [prop, value] of Object.entries(props)) {
209
+ const accepts = (candidate: string) =>
210
+ valueNames.has(candidate) ||
211
+ (forwardsAttributes && isHtmlAttribute(candidate));
212
+ if (!accepts(prop)) {
213
+ // A React spelling of an attribute the story could give as HTML
214
+ // spells it, the form every pane takes.
215
+ const html = HTML_ATTRIBUTE_NAMES.get(prop);
216
+ errors.push(
217
+ html !== undefined && accepts(html)
218
+ ? `${name}: ${where} sets unknown prop '${prop}'; a story spells native attributes as HTML does: '${html}'`
219
+ : `${name}: ${where} sets unknown prop '${prop}'`
220
+ );
221
+ continue;
222
+ }
223
+ if (prop === 'children' && component.defaultSlotScoped) {
224
+ errors.push(
225
+ `${name}: ${where} sets children, which ${name}'s default slot cannot take: it exposes values, so its content is a function of them, not text`
226
+ );
227
+ continue;
228
+ }
229
+ if (where.startsWith('scenario') && prop in fixed) {
230
+ errors.push(`${name}: ${where} sets '${prop}', which is fixed`);
231
+ }
232
+ // A scenario's undefined never reaches the panes, which keep the value
233
+ // the story starts the prop from; null is what unsets it. The scenario
234
+ // still renders, so this is a warning, an error under `strict`.
235
+ if (
236
+ value === undefined &&
237
+ where.startsWith('scenario') &&
238
+ model.defaultProps[prop] !== undefined &&
239
+ JSON.stringify(component.props[prop]?.default) !==
240
+ JSON.stringify(model.defaultProps[prop])
241
+ ) {
242
+ warnings.push(
243
+ `${name}: ${where} sets ${prop} to undefined, which leaves it at ${format(model.defaultProps[prop])}, the value the story starts it from: unset it with null`
244
+ );
245
+ continue;
246
+ }
247
+ const definition = component.props[prop];
248
+ if (!definition || value === undefined) continue;
249
+ // A two-way prop given null is controlled at null, and its code binds
250
+ // state starting at null, which the component's types take only where
251
+ // the prop is declared with null.
252
+ if (
253
+ value === null &&
254
+ (model.bindings ?? []).some((binding) => binding.prop === prop) &&
255
+ !unionMembers(definition.type).includes('null')
256
+ ) {
257
+ errors.push(
258
+ `${name}: ${where} gives two-way prop ${prop} null, which its type '${definition.type}' does not take: declare it '${definition.type} | null', or leave ${prop} out`
259
+ );
260
+ continue;
261
+ }
262
+ // The type as the control reads it: a named type expanded through
263
+ // the template's imports where it was.
264
+ const parsed = parseType(definition.resolvedType ?? definition.type);
265
+ // A prop with the color select takes one of the kit's color tokens;
266
+ // any other prop, color named or not, takes its declared union.
267
+ const isColor = model.propTypes.some(
268
+ (propType) =>
269
+ propType.name === prop && propType.control === 'color-select'
270
+ );
271
+ const allowed =
272
+ isColor && hints.colorTokens
273
+ ? hints.colorTokens.filter((token): token is string => token != null)
274
+ : parsed.kind === 'enum'
275
+ ? parsed.options
276
+ : undefined;
277
+ // A null unsets the prop (a two-way prop's is checked above).
278
+ if (allowed === undefined || value === null) continue;
279
+ // Beside its literals, the type may take any string (`string & {}`)
280
+ // or a structured value (an object, an array), which the select
281
+ // cannot offer and a story file sets.
282
+ const open = !isColor && parsed.kind === 'enum' && parsed.open === true;
283
+ const structural =
284
+ !isColor && parsed.kind === 'enum' && parsed.structural === true;
285
+ const takes =
286
+ typeof value === 'string'
287
+ ? open || allowed.includes(value)
288
+ : typeof value === 'boolean'
289
+ ? (allowed as (string | boolean)[]).includes(value)
290
+ : typeof value === 'object' && structural;
291
+ if (!takes) {
292
+ const options = allowed
293
+ .map((option) =>
294
+ typeof option === 'string' ? `'${option}'` : String(option)
295
+ )
296
+ .join(', ');
297
+ const besides = [
298
+ ...(open ? ['any string'] : []),
299
+ ...(structural ? ['a structured value'] : []),
300
+ ];
301
+ errors.push(
302
+ `${name}: ${where} sets ${prop} to ${format(value)}, not one of ${options}${besides.length ? ` or ${besides.join(' or ')}` : ''}`
303
+ );
304
+ }
305
+ }
306
+ }
307
+
308
+ // Named slots: a template's declared slot names, or for a driver read from
309
+ // code any prop it declares (in React a named or scoped slot is a prop).
310
+ const slotNames = new Set([
311
+ ...component.slotNames,
312
+ ...(component.memberNames ?? []),
313
+ ]);
314
+ // The story's plan, and each scenario's own, which fills the same slots.
315
+ const plans: Array<[string, SlotPlan | undefined]> = [
316
+ ['', story.slots],
317
+ ...(story.scenarios ?? []).map(
318
+ (scenario): [string, SlotPlan | undefined] => [
319
+ `scenario '${scenario.id}' `,
320
+ scenario.slots,
321
+ ]
322
+ ),
323
+ ];
324
+ // A slot whose content must take its scope takes a scoped fill alone: in
325
+ // React its prop is the render function, which plain content is not.
326
+ const scopeRequired = new Set(component.scopeRequiredSlots ?? []);
327
+ // The components the fills compose, at every depth, against what each
328
+ // declares.
329
+ const fillTrees: Array<[string, SlotFill | undefined]> = [];
330
+ for (const [where, plan] of plans) {
331
+ for (const slot of Object.keys(plan?.named ?? {})) {
332
+ if (!slotNames.has(slot))
333
+ errors.push(
334
+ `${name}: ${where}slots fill named slot '${slot}', which ${name} lacks`
335
+ );
336
+ }
337
+ const defaultFill = plan?.default;
338
+ const emptyFragment =
339
+ defaultFill?.kind === 'fragment' && defaultFill.children.length === 0;
340
+ if (defaultFill && !emptyFragment && !component.hasDefaultSlot) {
341
+ errors.push(
342
+ `${name}: ${where}slots fill the default slot, which ${name} lacks`
343
+ );
344
+ }
345
+ const fills: Array<[string, SlotFill | undefined]> = [
346
+ ['default', emptyFragment ? undefined : defaultFill],
347
+ ...Object.entries(plan?.named ?? {}),
348
+ ];
349
+ for (const [slot, fill] of fills) {
350
+ if (fill && fill.kind !== 'scoped' && scopeRequired.has(slot))
351
+ errors.push(
352
+ `${name}: ${where}slots fill ${slot === 'default' ? 'the default slot' : `named slot '${slot}'`} with plain content, which it cannot take: its content must take the values it exposes, so fill it with a scoped fill`
353
+ );
354
+ }
355
+ fillTrees.push(
356
+ [`${where}slots.default`, emptyFragment ? undefined : defaultFill],
357
+ ...Object.entries(plan?.named ?? {}).map(
358
+ ([slot, fill]) =>
359
+ [`${where}slots.named.${slot}`, fill] as [string, SlotFill]
360
+ )
361
+ );
362
+ }
363
+
364
+ for (const referenced of model.slotComponents) {
365
+ if (!knownComponents.has(referenced)) {
366
+ errors.push(
367
+ `${name}: slot content references '${referenced}', which no source declares`
368
+ );
369
+ }
370
+ }
371
+
372
+ fillTrees.push(
373
+ ['around.before', story.around?.before],
374
+ ['around.after', story.around?.after]
375
+ );
376
+ for (const [where, fill] of fillTrees)
377
+ checkFillTree(name, where, fill, knownComponents, errors);
378
+ }
379
+
380
+ /**
381
+ * Checks the option values a component's scenarios set against the kit's
382
+ * options: each must name one of them, hold a value it accepts, and not be
383
+ * one the story file makes unavailable for the component.
384
+ */
385
+ function checkScenarioOptions(
386
+ { component, story }: LoadedComponent,
387
+ previewOptions: unknown,
388
+ errors: string[]
389
+ ): void {
390
+ const options = Array.isArray(previewOptions)
391
+ ? (previewOptions as PreviewOption[])
392
+ : [];
393
+ for (const scenario of story?.scenarios ?? []) {
394
+ for (const [id, value] of Object.entries(scenario.options ?? {})) {
395
+ const where = `${component.name}: scenario '${scenario.id}'`;
396
+ const option = options.find((candidate) => candidate?.id === id);
397
+ if (!option) {
398
+ errors.push(`${where} sets unknown option '${id}'`);
399
+ } else if (!acceptsOptionValue(option, value)) {
400
+ errors.push(
401
+ `${where} sets option '${id}' to ${JSON.stringify(value)}, which it cannot hold`
402
+ );
403
+ } else if (story?.options?.[id] === false) {
404
+ errors.push(
405
+ `${where} sets option '${id}', which the story file makes unavailable`
406
+ );
407
+ }
408
+ }
409
+ }
410
+ }
411
+
412
+ /**
413
+ * Whether a package's `exports` exports a subpath, as Node resolves it: an
414
+ * entry for the subpath itself, or else the subpath pattern (`./*`) with the
415
+ * longest matching prefix. An entry whose value is `null` excludes the
416
+ * subpath.
417
+ */
418
+ function exportsSubpath(exports: unknown, subpath: string): boolean {
419
+ if (exports === null || typeof exports !== 'object' || Array.isArray(exports))
420
+ return false;
421
+ const entries = exports as Record<string, unknown>;
422
+ if (subpath in entries) return entries[subpath] !== null;
423
+ let best: { prefix: string; value: unknown } | undefined;
424
+ for (const [key, value] of Object.entries(entries)) {
425
+ const star = key.indexOf('*');
426
+ if (star === -1 || key.indexOf('*', star + 1) !== -1) continue;
427
+ const prefix = key.slice(0, star);
428
+ const suffix = key.slice(star + 1);
429
+ if (
430
+ subpath.length >= prefix.length + suffix.length &&
431
+ subpath.startsWith(prefix) &&
432
+ subpath.endsWith(suffix) &&
433
+ (best === undefined || prefix.length > best.prefix.length)
434
+ )
435
+ best = { prefix, value };
436
+ }
437
+ return best !== undefined && best.value !== null;
438
+ }
439
+
440
+ /**
441
+ * Whether a package's `exports` exports a shared module's subpath, or paths
442
+ * below it, which the kit's built files import as `<specifier>/<path>`: an
443
+ * entry for the subpath (see {@link exportsSubpath}) or any entry below it,
444
+ * such as the pattern `./scripts/*` for `./scripts`, which the kit's
445
+ * files import paths below the specifier by. The package's own name,
446
+ * `.`, is also exported by a string or array `exports`, or an object of
447
+ * conditions alone (`{ "import": ..., "types": ... }`).
448
+ */
449
+ function exportsShared(exports: unknown, subpath: string): boolean {
450
+ if (subpath === '.') {
451
+ if (typeof exports === 'string' || Array.isArray(exports)) return true;
452
+ if (
453
+ exports !== null &&
454
+ typeof exports === 'object' &&
455
+ Object.keys(exports).length > 0 &&
456
+ !Object.keys(exports).some((key) => key.startsWith('.'))
457
+ )
458
+ return true;
459
+ }
460
+ if (exportsSubpath(exports, subpath)) return true;
461
+ if (
462
+ subpath === '.' ||
463
+ exports === null ||
464
+ typeof exports !== 'object' ||
465
+ Array.isArray(exports)
466
+ )
467
+ return false;
468
+ return Object.entries(exports as Record<string, unknown>).some(
469
+ ([key, value]) => key.startsWith(`${subpath}/`) && value !== null
470
+ );
471
+ }
472
+
473
+ /**
474
+ * The entry a package's `exports` could give a shared module's source: the
475
+ * file itself, or a directory's `index.ts` or `index.js` when it has one,
476
+ * relative to the package's directory. `undefined` when the source is a
477
+ * directory with neither.
478
+ */
479
+ function sharedEntry(
480
+ packageDirectory: string,
481
+ kitRoot: string,
482
+ source: string
483
+ ): string | undefined {
484
+ const absolute = resolve(kitRoot, source);
485
+ const path = `./${relative(packageDirectory, absolute).split(sep).join('/')}`;
486
+ if (!statSync(absolute, { throwIfNoEntry: false })?.isDirectory())
487
+ return path;
488
+ const index = ['index.ts', 'index.js'].find((file) =>
489
+ existsSync(resolve(absolute, file))
490
+ );
491
+ return index === undefined ? undefined : `${path}/${index}`;
492
+ }
493
+
494
+ /** A package of the kit whose `package.json` the check reads. */
495
+ interface CheckedPackage {
496
+ /** What the messages call its exports, such as "the kit's exports". */
497
+ label: string;
498
+ name: string;
499
+ directory: string;
500
+ packagePath: string;
501
+ exports: unknown;
502
+ }
503
+
504
+ /**
505
+ * A package's `package.json`, read for its name and `exports`; `undefined`
506
+ * when it has none, and an error when it is not JSON.
507
+ */
508
+ function readCheckedPackage(
509
+ directory: string,
510
+ label: string,
511
+ errors: string[]
512
+ ): CheckedPackage | undefined {
513
+ const packagePath = resolve(directory, 'package.json');
514
+ if (!existsSync(packagePath)) return undefined;
515
+ let manifest: { name?: unknown; exports?: unknown };
516
+ try {
517
+ manifest = JSON.parse(readFileSync(packagePath, 'utf8')) as typeof manifest;
518
+ } catch (error) {
519
+ errors.push(
520
+ `${packagePath}: cannot be read as JSON (${(error as Error).message})`
521
+ );
522
+ return undefined;
523
+ }
524
+ const { name, exports } = manifest ?? {};
525
+ if (typeof name !== 'string') return undefined;
526
+ return { label, name, directory, packagePath, exports };
527
+ }
528
+
529
+ /**
530
+ * Checks that the kit's package exports each path the preview imports it
531
+ * by: a source whose `moduleBase` is the kit's own package name imports
532
+ * `<name>/<target>`, which resolves only through an `exports` entry for
533
+ * `./<target>` (or a subpath pattern covering it), and the kit's built
534
+ * files import each shared module by its `add.shared` `specifier`, which
535
+ * resolves the same way when it is a path of the kit's own package. A
536
+ * module the kit aliases is resolved by the alias instead.
537
+ *
538
+ * A kit built into one package per target (its configuration's
539
+ * `packages`) is checked as its packages: a shared module's specifier
540
+ * against the exports of the package it names (the kit root's or the
541
+ * styles package's), the kit's own source against the target packages'
542
+ * names, and each package for resolving from the kit root.
543
+ */
544
+ function checkTargetExports(context: PreviewContext, errors: string[]): void {
545
+ const aliases = context.configuration.aliases ?? {};
546
+ const kit = readCheckedPackage(context.kitRoot, "the kit's exports", errors);
547
+ const layout = context.kitPackages;
548
+ const styles = layout?.find(({ role }) => role === 'styles');
549
+ const styled =
550
+ styles === undefined
551
+ ? undefined
552
+ : readCheckedPackage(
553
+ styles.directoryAbsolute,
554
+ "the styles package's exports",
555
+ errors
556
+ );
557
+ const exporting = [kit, styled].filter(
558
+ (checked): checked is CheckedPackage =>
559
+ checked !== undefined && !(checked.name in aliases)
560
+ );
561
+ for (const { source, specifier } of context.sharedSpecifiers) {
562
+ const holder = exporting
563
+ .filter(
564
+ ({ name }) => specifier === name || specifier.startsWith(`${name}/`)
565
+ )
566
+ .sort((a, b) => b.name.length - a.name.length)[0];
567
+ if (holder === undefined) continue;
568
+ const subpath = `.${specifier.slice(holder.name.length)}`;
569
+ if (specifier in aliases || exportsShared(holder.exports, subpath))
570
+ continue;
571
+ const entry = sharedEntry(holder.directory, context.kitRoot, source);
572
+ errors.push(
573
+ `${holder.packagePath}: add.shared gives '${source}' the specifier '${specifier}', but ${holder.label} has no '${subpath}' entry, so the preview and the kit's consumers cannot import it; ${
574
+ entry === undefined
575
+ ? `add one naming the module '${source}' exports from`
576
+ : `add one, such as "${subpath}": "${entry}"`
577
+ }`
578
+ );
579
+ }
580
+ if (layout !== undefined) {
581
+ checkLayoutSources(context, kit?.name, errors);
582
+ checkLayoutResolves(context, layout, errors);
583
+ return;
584
+ }
585
+ if (kit === undefined || kit.name in aliases) return;
586
+ const { name, exports, packagePath } = kit;
587
+ if (!context.sources.some((source) => source.moduleBase === name)) return;
588
+ for (const target of context.targets) {
589
+ const specifier = `${name}/${target}`;
590
+ if (specifier in aliases || exportsSubpath(exports, `./${target}`))
591
+ continue;
592
+ errors.push(
593
+ `${packagePath}: the kit builds the ${target} target, but its package.json has no exports entry for './${target}', so the preview cannot import '${specifier}'; add one, such as "./${target}": "./dist/${target}/index.ts"`
594
+ );
595
+ }
596
+ }
597
+
598
+ /**
599
+ * The `moduleBase` with `{target}` whose module for each target is that
600
+ * target's package name, when one covers them all.
601
+ */
602
+ function targetPattern(
603
+ packages: readonly KitLayoutPackage[]
604
+ ): string | undefined {
605
+ const [first] = packages;
606
+ if (first === undefined) return undefined;
607
+ for (
608
+ let index = first.name.indexOf(first.role);
609
+ index !== -1;
610
+ index = first.name.indexOf(first.role, index + 1)
611
+ ) {
612
+ const pattern = `${first.name.slice(0, index)}${TARGET_TOKEN}${first.name.slice(index + first.role.length)}`;
613
+ if (
614
+ packages.every(({ role, name }) => moduleOfSource(pattern, role) === name)
615
+ )
616
+ return pattern;
617
+ }
618
+ return undefined;
619
+ }
620
+
621
+ /**
622
+ * Checks, for a kit built into one package per target, that the
623
+ * kit's own source imports each target from its package: a source naming the
624
+ * kit (its package name or kit name, without `{target}`) or a `{target}`
625
+ * pattern that yields some target packages' names but not the rest is
626
+ * reported, with the pattern that covers the configured names.
627
+ */
628
+ function checkLayoutSources(
629
+ context: PreviewContext,
630
+ kitPackageName: string | undefined,
631
+ errors: string[]
632
+ ): void {
633
+ const aliases = context.configuration.aliases ?? {};
634
+ const targetPackages = (context.kitPackages ?? []).filter(
635
+ ({ role }) => role !== 'styles'
636
+ );
637
+ if (targetPackages.length === 0) return;
638
+ const pattern = targetPattern(targetPackages);
639
+ const remedy =
640
+ pattern === undefined
641
+ ? `name the per-target packages so one pattern with '${TARGET_TOKEN}' covers them all (such as '${context.kitName}-${TARGET_TOKEN}'), and set moduleBase to it`
642
+ : `set moduleBase to '${pattern}'`;
643
+ for (const source of context.sources) {
644
+ const { moduleBase } = source;
645
+ const namesKit =
646
+ !moduleBase.includes(TARGET_TOKEN) &&
647
+ (moduleBase === kitPackageName || moduleBase === context.kitName);
648
+ const matches = targetPackages.filter(
649
+ ({ role, name }) => moduleOfSource(moduleBase, role) === name
650
+ );
651
+ if (!namesKit && matches.length === 0) continue;
652
+ for (const { role, name } of targetPackages) {
653
+ const imported = moduleOfSource(moduleBase, role);
654
+ if (imported === name || imported in aliases) continue;
655
+ errors.push(
656
+ `source '${source.id}': moduleBase '${moduleBase}' imports '${imported}', but the ${role} target is built into '${name}'; ${remedy}`
657
+ );
658
+ // One message per source says what to change.
659
+ break;
660
+ }
661
+ }
662
+ }
663
+
664
+ /**
665
+ * Checks that each package of a kit built into one package per target
666
+ * resolves from the kit root, as the preview imports it by name.
667
+ */
668
+ function checkLayoutResolves(
669
+ context: PreviewContext,
670
+ layout: readonly KitLayoutPackage[],
671
+ errors: string[]
672
+ ): void {
673
+ const aliases = context.configuration.aliases ?? {};
674
+ for (const { role, name } of layout) {
675
+ if (name in aliases || resolvesFrom(name, context.kitRoot)) continue;
676
+ errors.push(
677
+ `packages.${role}: '${name}' does not resolve from the kit root, so the preview cannot import it; add it to the kit's workspace or as a dev dependency of the kit root`
678
+ );
679
+ }
680
+ }
681
+
682
+ type DiagnoseKit = (
683
+ kitDirectory: string,
684
+ options: { configuration: string }
685
+ ) => Promise<KitDiagnostics>;
686
+
687
+ /** `diagnoseKit` from a module's exports, ES or CommonJS, when it has one. */
688
+ function diagnoseKitOf(module: unknown): DiagnoseKit | undefined {
689
+ const exports = module as {
690
+ diagnoseKit?: unknown;
691
+ default?: { diagnoseKit?: unknown };
692
+ };
693
+ const found = exports.diagnoseKit ?? exports.default?.diagnoseKit;
694
+ return typeof found === 'function' ? (found as DiagnoseKit) : undefined;
695
+ }
696
+
697
+ /** Whether a report is the shape `diagnoseKit` returns: lists of lines. */
698
+ function isKitDiagnostics(report: unknown): report is KitDiagnostics {
699
+ const lines = (value: unknown) =>
700
+ Array.isArray(value) && value.every((line) => typeof line === 'string');
701
+ return (
702
+ typeof report === 'object' &&
703
+ report !== null &&
704
+ lines((report as KitDiagnostics).errors) &&
705
+ lines((report as KitDiagnostics).warnings)
706
+ );
707
+ }
708
+
709
+ /** Whether an error says a module is not there to load. */
710
+ function isMissingModule(error: unknown): boolean {
711
+ const code = (error as { code?: unknown } | null)?.code;
712
+ return (
713
+ code === 'MODULE_NOT_FOUND' || code === 'ERR_PACKAGE_PATH_NOT_EXPORTED'
714
+ );
715
+ }
716
+
717
+ /**
718
+ * What `scaffold-ui-kit build` reports for the kit's components, by the
719
+ * scaffolder the kit builds with: the kit's own installed copy, else the
720
+ * preview's. A kit copy that cannot be loaded, has no `diagnoseKit` or
721
+ * returns something other than its report is set aside for the preview's,
722
+ * with a warning saying why; a kit with none installed is not warned of. A
723
+ * kit with no `src/components/`, whose components are all manual or come
724
+ * from another directory, has nothing the build renders, and gets nothing
725
+ * here.
726
+ */
727
+ async function buildDiagnostics(
728
+ kitRoot: string,
729
+ configuration: string
730
+ ): Promise<KitDiagnostics> {
731
+ if (!existsSync(join(kitRoot, 'src', 'components')))
732
+ return { errors: [], warnings: [] };
733
+ const fallback = async (reason?: string): Promise<KitDiagnostics> => {
734
+ const own = diagnoseKitOf(await import('scaffold-ui-kit'));
735
+ const report = own
736
+ ? await own(kitRoot, { configuration })
737
+ : { errors: [], warnings: [] };
738
+ return reason === undefined
739
+ ? report
740
+ : {
741
+ errors: report.errors,
742
+ warnings: [
743
+ `the kit's scaffold-ui-kit ${reason}, so the build's diagnostics come from the preview's own copy`,
744
+ ...report.warnings,
745
+ ],
746
+ };
747
+ };
748
+ let entry: string;
749
+ try {
750
+ entry = createRequire(join(kitRoot, 'package.json')).resolve(
751
+ 'scaffold-ui-kit'
752
+ );
753
+ } catch (error) {
754
+ if (isMissingModule(error)) return fallback();
755
+ throw error;
756
+ }
757
+ let module: unknown;
758
+ try {
759
+ module = await import(pathToFileURL(entry).href);
760
+ } catch (error) {
761
+ return fallback(
762
+ `could not be loaded (${error instanceof Error ? error.message : String(error)})`
763
+ );
764
+ }
765
+ const diagnoseKit = diagnoseKitOf(module);
766
+ if (!diagnoseKit)
767
+ return fallback(
768
+ 'exports no diagnoseKit; update scaffold-ui-kit in the kit'
769
+ );
770
+ const report: unknown = await diagnoseKit(kitRoot, { configuration });
771
+ if (!isKitDiagnostics(report))
772
+ return fallback(
773
+ "returned a report that isn't lists of errors and warnings"
774
+ );
775
+ return report;
776
+ }
777
+
778
+ /** The component a tag names, or undefined for an HTML element: the part
779
+ * before any `.`, when it starts with a capital. A dynamic tag names its
780
+ * `default`. */
781
+ function componentOfTag(tag: unknown): string | undefined {
782
+ const name =
783
+ typeof tag === 'string'
784
+ ? tag
785
+ : tag && typeof tag === 'object'
786
+ ? (tag as { default?: unknown }).default
787
+ : undefined;
788
+ if (typeof name !== 'string') return undefined;
789
+ const head = name.split('.')[0];
790
+ return /^[A-Z]/.test(head) ? head : undefined;
791
+ }
792
+
793
+ /** Every component an element or fragment in a template tree renders, through
794
+ * its tag or a target's `tag` override. */
795
+ function collectRendered(node: unknown, into: Set<string>): void {
796
+ if (!node || typeof node !== 'object') return;
797
+ if (Array.isArray(node)) {
798
+ for (const child of node) collectRendered(child, into);
799
+ return;
800
+ }
801
+ const record = node as Record<string, unknown>;
802
+ if (record.type === 'element' || record.type === 'fragment') {
803
+ const own = componentOfTag(record.tag);
804
+ if (own) into.add(own);
805
+ const extensions = record.extensions;
806
+ if (extensions && typeof extensions === 'object') {
807
+ for (const override of Object.values(extensions)) {
808
+ if (override && typeof override === 'object') {
809
+ const named = componentOfTag((override as { tag?: unknown }).tag);
810
+ if (named) into.add(named);
811
+ }
812
+ }
813
+ }
814
+ }
815
+ for (const value of Object.values(record)) collectRendered(value, into);
816
+ }
817
+
818
+ /** Each component's renderers: the other templates whose trees render it,
819
+ * omitted ones included. A story file's slot plan is not a renderer. */
820
+ function templateRenderers(
821
+ components: LoadedComponent[]
822
+ ): Map<string, Set<string>> {
823
+ const renderers = new Map<string, Set<string>>();
824
+ for (const { component } of components) {
825
+ if (!component.template) continue;
826
+ const rendered = new Set<string>();
827
+ collectRendered(component.template, rendered);
828
+ rendered.delete(component.name);
829
+ for (const name of rendered) {
830
+ const set = renderers.get(name) ?? new Set<string>();
831
+ set.add(component.name);
832
+ renderers.set(name, set);
833
+ }
834
+ }
835
+ return renderers;
836
+ }
837
+
838
+ /** The labels of the stories a reader browses, each as `<source> <label>`: a
839
+ * standalone component neither omitted nor unlisted, whose story file names its
840
+ * story. A label stands in for a component of its own source only. */
841
+ function labelsOfStories(
842
+ components: LoadedComponent[],
843
+ omitPattern?: RegExp
844
+ ): Set<string> {
845
+ const labels = new Set<string>();
846
+ for (const { component, story, source } of components) {
847
+ const label = story?.label;
848
+ if (typeof label !== 'string') continue;
849
+ const omit =
850
+ (story?.omit ?? false) ||
851
+ isStructurallyOmitted(component.name, omitPattern);
852
+ const standalone =
853
+ (story?.standalone ?? true) && (component.standalone ?? true);
854
+ const unlisted =
855
+ (story?.unlisted ?? false) || (component.unlisted ?? false);
856
+ if (!omit && standalone && !unlisted) labels.add(`${source.id} ${label}`);
857
+ }
858
+ return labels;
859
+ }
860
+
861
+ /** Whether a component's template is a compound's root, listing parts. */
862
+ function listsParts(template: unknown): boolean {
863
+ if (!template || typeof template !== 'object' || Array.isArray(template))
864
+ return false;
865
+ const parts = (template as { parts?: unknown }).parts;
866
+ return Array.isArray(parts) && parts.length > 0;
867
+ }
868
+
869
+ /**
870
+ * Checks a kit's preview story files and manual specs against its components.
871
+ *
872
+ * @param kitRoot - The kit root (where its package.json lives)
873
+ * @param options - `strict` turns coverage warnings into errors; `configuration`
874
+ * names the kit configuration file, as `--configuration` does
875
+ * @returns The errors, warnings and hidden props, each a line naming its component
876
+ * @example
877
+ * ```ts
878
+ * it('keeps the preview in step with the components', async () => {
879
+ * const report = await checkPreview(process.cwd());
880
+ * expect(report.errors).toEqual([]);
881
+ * });
882
+ * ```
883
+ */
884
+ export async function checkPreview(
885
+ kitRoot: string,
886
+ options: PreviewCheckOptions = {}
887
+ ): Promise<PreviewCheckReport> {
888
+ const context = loadPreviewContext(kitRoot, {
889
+ configuration: options.configuration,
890
+ workingDirectory: options.workingDirectory,
891
+ });
892
+ const { components, parts } = await loadKit(context);
893
+ const kitConfiguration = context.configuration;
894
+ const hints = kitConfiguration.controlHints ?? {};
895
+ const errors: string[] = [];
896
+ const warnings: string[] = [];
897
+ const hidden: string[] = [];
898
+ const ungenerated: string[] = [];
899
+ const knownComponents = new Map(
900
+ components.map((loaded) => [loaded.component.name, loaded.component])
901
+ );
902
+ const renderers = templateRenderers(components);
903
+ const storyLabels = labelsOfStories(components, context.omitPattern);
904
+ errors.push(...optionProblems(kitConfiguration.options));
905
+ errors.push(...kitPaneWidths(context).errors);
906
+ const shellProblem = missingShellPeersProblem(missingShellPeers(context));
907
+ if (shellProblem !== undefined) errors.push(shellProblem);
908
+ // The shell's page, loaded as `dev` and `build` load it.
909
+ try {
910
+ await loadShellPage(context, { hostPage: HOST_PAGE, serving: false });
911
+ } catch (error) {
912
+ errors.push((error as Error).message.replace(/^\[preview\] /, ''));
913
+ }
914
+ checkTargetExports(context, errors);
915
+ // What the kit's own build reports for its components, as it reports it:
916
+ // a story of a component the build fails has nothing to show.
917
+ const build = await buildDiagnostics(
918
+ resolve(kitRoot),
919
+ context.configurationPath
920
+ );
921
+ errors.push(...build.errors);
922
+ // The browseable stories, which the landing entry may name.
923
+ const stories: ResolvedStory[] = [];
924
+
925
+ for (const loaded of components) {
926
+ const { component, story } = loaded;
927
+ let model: ResolvedStory;
928
+ try {
929
+ model = resolveStory(
930
+ component,
931
+ story,
932
+ hints,
933
+ kitConfiguration.labels,
934
+ context.omitPattern,
935
+ context.targets
936
+ );
937
+ } catch (error) {
938
+ errors.push((error as Error).message);
939
+ continue;
940
+ }
941
+ // `unlisted` hides a story; an omitted component or one that is not
942
+ // standalone has none.
943
+ if ((story?.unlisted ?? false) || (component.unlisted ?? false)) {
944
+ if (model.omit)
945
+ errors.push(
946
+ `${component.name}: unlisted has no story to hide, as the component is omitted`
947
+ );
948
+ else if (!model.standalone)
949
+ errors.push(
950
+ `${component.name}: unlisted has no story to hide, as the component is standalone: false`
951
+ );
952
+ }
953
+ // A compound's root names its parts in its template, or in the kit
954
+ // configuration when it has none; a story file's
955
+ // `standalone: false` alone still takes a component out of the navigation,
956
+ // but no compound copies it whole. One that is no one compound's part
957
+ // needs no listing: a block two
958
+ // templates render, a component a labeled story stands in for, or a
959
+ // root with parts of its own.
960
+ if (
961
+ story?.standalone === false &&
962
+ !parts.has(component.name) &&
963
+ (renderers.get(component.name)?.size ?? 0) < 2 &&
964
+ !storyLabels.has(`${loaded.source.id} ${component.name}`) &&
965
+ !listsParts(component.template) &&
966
+ (context.parts[component.name]?.length ?? 0) === 0
967
+ )
968
+ warnings.push(
969
+ `${component.name}: its story file sets standalone: false, but no compound lists it in parts, fewer than two templates render it, no story in its source is labeled '${component.name}' and it lists no parts of its own; list it in its root's parts`
970
+ );
971
+ if (!model.omit && model.standalone) stories.push(model);
972
+ checkComponent(loaded, model, knownComponents, hints, errors, warnings);
973
+ checkScenarioOptions(loaded, kitConfiguration.options, errors);
974
+
975
+ // Omitted components get no story, so their coverage is moot. A prop the
976
+ // story file's `generate` leaves out is out of coverage by that choice, and is
977
+ // listed as ungenerated instead.
978
+ if (!model.omit && model.standalone) {
979
+ const generate = story?.generate;
980
+ const ungeneratedProps = new Set<string>();
981
+ for (const { prop, value } of scenarioValues(
982
+ model.propTypes,
983
+ model.defaultProps
984
+ )) {
985
+ if (!generatesProp(generate, prop)) {
986
+ ungeneratedProps.add(prop);
987
+ continue;
988
+ }
989
+ if (
990
+ !model.scenarios.some((scenario) => scenario.props?.[prop] === value)
991
+ ) {
992
+ const id = generatedScenarioId(prop, value);
993
+ const replacing = story?.scenarios?.some(
994
+ (scenario) => scenario.id === id
995
+ );
996
+ warnings.push(
997
+ `${component.name}: no scenario sets ${prop} to ${format(value)}` +
998
+ (replacing
999
+ ? ` (scenario '${id}' replaces the generated one and does not set it)`
1000
+ : '')
1001
+ );
1002
+ }
1003
+ }
1004
+ for (const prop of ungeneratedProps)
1005
+ ungenerated.push(`${component.name}: ${prop}`);
1006
+ }
1007
+
1008
+ for (const propType of model.propTypes) {
1009
+ const excluded =
1010
+ story?.controls?.[propType.name] === false ||
1011
+ propType.name in (story?.fixed ?? {});
1012
+ if (!propType.include && !excluded) {
1013
+ hidden.push(
1014
+ `${component.name}: ${propType.name} (${component.props[propType.name].type})`
1015
+ );
1016
+ }
1017
+ }
1018
+ }
1019
+
1020
+ // A story file that matches no component in its source is never read. A
1021
+ // stories directory the kit has not made yet holds none.
1022
+ for (const source of context.sources) {
1023
+ if (
1024
+ !source.storiesDirectoryAbsolute ||
1025
+ !existsSync(source.storiesDirectoryAbsolute)
1026
+ )
1027
+ continue;
1028
+ const componentNames = new Set(
1029
+ components
1030
+ .filter((loaded) => loaded.source.id === source.id)
1031
+ .map((loaded) => loaded.component.name)
1032
+ );
1033
+ for (const file of readdirSync(source.storiesDirectoryAbsolute)) {
1034
+ if (!file.endsWith(STORY_FILE_SUFFIX)) continue;
1035
+ const name = file.slice(0, -STORY_FILE_SUFFIX.length);
1036
+ if (!componentNames.has(name)) {
1037
+ errors.push(
1038
+ `${name}: ${resolve(source.storiesDirectoryAbsolute, file)} matches no component in source '${source.id}'; name it after a template, or list a hand-written component in the source's manualModule`
1039
+ );
1040
+ }
1041
+ }
1042
+ }
1043
+
1044
+ errors.push(...landingProblems(context.configuration.landing, stories));
1045
+
1046
+ // `strict` makes the coverage gaps errors; what reading the kit
1047
+ // configuration warns of, such as a file name it still accepts, and the
1048
+ // build's warnings stay warnings.
1049
+ return options.strict
1050
+ ? {
1051
+ errors: [...errors, ...warnings],
1052
+ warnings: [...context.warnings, ...build.warnings],
1053
+ hidden,
1054
+ ungenerated,
1055
+ }
1056
+ : {
1057
+ errors,
1058
+ warnings: [...context.warnings, ...build.warnings, ...warnings],
1059
+ hidden,
1060
+ ungenerated,
1061
+ };
1062
+ }