handoff-app 1.2.2-7 → 2.0.0-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 (349) hide show
  1. package/Changelog.md +37 -15
  2. package/README.md +372 -118
  3. package/dist/app-builder/build.d.ts +41 -2
  4. package/dist/app-builder/build.js +455 -18
  5. package/dist/app-builder/client-config.d.ts +15 -2
  6. package/dist/app-builder/client-config.js +43 -4
  7. package/dist/app-builder/config-diff/snapshot.d.ts +1 -6
  8. package/dist/app-builder/config-diff/snapshot.js +2 -9
  9. package/dist/app-builder/index.d.ts +1 -1
  10. package/dist/app-builder/index.js +3 -1
  11. package/dist/app-builder/nav-shell.d.ts +23 -0
  12. package/dist/app-builder/nav-shell.js +80 -0
  13. package/dist/app-builder/static-export.d.ts +17 -0
  14. package/dist/app-builder/static-export.js +181 -0
  15. package/dist/app-builder/vercel-output.d.ts +39 -0
  16. package/dist/app-builder/vercel-output.js +259 -0
  17. package/dist/app-builder/watchers/utils.d.ts +2 -0
  18. package/dist/app-builder/watchers.d.ts +14 -4
  19. package/dist/app-builder/watchers.js +123 -28
  20. package/dist/app-builder/websocket.js +1 -1
  21. package/dist/artifacts/index.d.ts +2 -0
  22. package/dist/artifacts/index.js +13 -0
  23. package/dist/artifacts/types.d.ts +136 -0
  24. package/dist/artifacts/types.js +24 -0
  25. package/dist/artifacts/url.d.ts +49 -0
  26. package/dist/artifacts/url.js +86 -0
  27. package/dist/cache/build-cache.js +48 -4
  28. package/dist/cli.js +0 -0
  29. package/dist/commands/build/app.js +1 -2
  30. package/dist/commands/build/index.d.ts +17 -0
  31. package/dist/commands/build/index.js +66 -0
  32. package/dist/commands/checkout/index.d.ts +24 -0
  33. package/dist/commands/checkout/index.js +71 -0
  34. package/dist/commands/db/migrate.d.ts +11 -0
  35. package/dist/commands/db/migrate.js +33 -0
  36. package/dist/commands/index.js +8 -0
  37. package/dist/commands/publish/index.d.ts +21 -0
  38. package/dist/commands/publish/index.js +68 -0
  39. package/dist/config/defaults.js +15 -1
  40. package/dist/config/loader.js +6 -1
  41. package/dist/config/normalizers/declaration.js +4 -1
  42. package/dist/config/normalizers/page.d.ts +18 -0
  43. package/dist/config/normalizers/page.js +34 -0
  44. package/dist/config/normalizers/pattern.js +7 -1
  45. package/dist/config/runtime.d.ts +4 -0
  46. package/dist/config/runtime.js +53 -5
  47. package/dist/index.d.ts +65 -1
  48. package/dist/index.js +123 -3
  49. package/dist/nav/index.d.ts +83 -0
  50. package/dist/nav/index.js +9 -0
  51. package/dist/nav/registry.d.ts +15 -0
  52. package/dist/nav/registry.js +42 -0
  53. package/dist/nav/resolver.d.ts +8 -0
  54. package/dist/nav/resolver.js +192 -0
  55. package/dist/nav/workspace.d.ts +18 -0
  56. package/dist/nav/workspace.js +103 -0
  57. package/dist/pipeline/styles.js +7 -35
  58. package/dist/pipeline/token-transformers.d.ts +45 -0
  59. package/dist/pipeline/token-transformers.js +71 -0
  60. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +17 -0
  61. package/dist/registry/asset-storage/adapters/vercel-blob.js +87 -0
  62. package/dist/registry/asset-storage/define.d.ts +10 -0
  63. package/dist/registry/asset-storage/define.js +13 -0
  64. package/dist/registry/asset-storage/resolve.d.ts +45 -0
  65. package/dist/registry/asset-storage/resolve.js +36 -0
  66. package/dist/registry/asset-storage/types.d.ts +51 -0
  67. package/dist/registry/asset-storage/types.js +9 -0
  68. package/dist/registry/assets/layout.d.ts +22 -0
  69. package/dist/registry/assets/layout.js +30 -0
  70. package/dist/registry/assets/sets.d.ts +18 -0
  71. package/dist/registry/assets/sets.js +44 -0
  72. package/dist/registry/assets/transfer.d.ts +45 -0
  73. package/dist/registry/assets/transfer.js +11 -0
  74. package/dist/registry/checkout/assets.d.ts +16 -0
  75. package/dist/registry/checkout/assets.js +150 -0
  76. package/dist/registry/checkout/index.d.ts +33 -0
  77. package/dist/registry/checkout/index.js +610 -0
  78. package/dist/registry/checkout/tokens.d.ts +15 -0
  79. package/dist/registry/checkout/tokens.js +171 -0
  80. package/dist/registry/client.d.ts +61 -0
  81. package/dist/registry/client.js +211 -0
  82. package/dist/registry/connection.d.ts +30 -0
  83. package/dist/registry/connection.js +36 -0
  84. package/dist/registry/db/adapter.d.ts +43 -0
  85. package/dist/registry/db/adapter.js +59 -0
  86. package/dist/registry/db/client.d.ts +35 -0
  87. package/dist/registry/db/client.js +94 -0
  88. package/dist/registry/db/index.d.ts +5 -0
  89. package/dist/registry/db/index.js +24 -0
  90. package/dist/registry/db/migrate.d.ts +23 -0
  91. package/dist/registry/db/migrate.js +64 -0
  92. package/dist/registry/db/schema.d.ts +4869 -0
  93. package/dist/registry/db/schema.js +347 -0
  94. package/dist/registry/path.d.ts +4 -0
  95. package/dist/registry/path.js +31 -0
  96. package/dist/registry/publish/artifacts.d.ts +16 -0
  97. package/dist/registry/publish/artifacts.js +132 -0
  98. package/dist/registry/publish/assets.d.ts +16 -0
  99. package/dist/registry/publish/assets.js +129 -0
  100. package/dist/registry/publish/errors.d.ts +5 -0
  101. package/dist/registry/publish/errors.js +26 -0
  102. package/dist/registry/publish/index.d.ts +27 -0
  103. package/dist/registry/publish/index.js +126 -0
  104. package/dist/registry/publish/package.d.ts +27 -0
  105. package/dist/registry/publish/package.js +257 -0
  106. package/dist/registry/publish/publish-build.d.ts +12 -0
  107. package/dist/registry/publish/publish-build.js +43 -0
  108. package/dist/registry/publish/tokens.d.ts +18 -0
  109. package/dist/registry/publish/tokens.js +112 -0
  110. package/dist/registry/tokens/sets.d.ts +59 -0
  111. package/dist/registry/tokens/sets.js +109 -0
  112. package/dist/registry/tokens/transfer.d.ts +48 -0
  113. package/dist/registry/tokens/transfer.js +10 -0
  114. package/dist/registry/transfer.d.ts +75 -0
  115. package/dist/registry/transfer.js +11 -0
  116. package/dist/store/filesystem-assets.d.ts +17 -0
  117. package/dist/store/filesystem-assets.js +147 -0
  118. package/dist/store/filesystem.d.ts +76 -0
  119. package/dist/store/filesystem.js +208 -0
  120. package/dist/store/index.d.ts +20 -0
  121. package/dist/store/index.js +38 -0
  122. package/dist/store/registry.d.ts +77 -0
  123. package/dist/store/registry.js +350 -0
  124. package/dist/store/source-files.d.ts +24 -0
  125. package/dist/store/source-files.js +147 -0
  126. package/dist/store/types.d.ts +185 -0
  127. package/dist/store/types.js +12 -0
  128. package/dist/transformers/plugins/csf-render.js +17 -8
  129. package/dist/transformers/plugins/handlebars-previews.js +10 -3
  130. package/dist/transformers/plugins/ssr-render.js +116 -49
  131. package/dist/transformers/preview/component/api.js +3 -1
  132. package/dist/transformers/preview/component/artifacts.js +8 -1
  133. package/dist/transformers/preview/component/builder.js +13 -5
  134. package/dist/transformers/preview/component/css.js +9 -1
  135. package/dist/transformers/preview/component/javascript.d.ts +9 -3
  136. package/dist/transformers/preview/component/javascript.js +45 -17
  137. package/dist/transformers/preview/component/shared-artifacts.d.ts +83 -0
  138. package/dist/transformers/preview/component/shared-artifacts.js +92 -0
  139. package/dist/transformers/preview/component.js +5 -2
  140. package/dist/transformers/preview/pattern/builder.js +28 -9
  141. package/dist/transformers/preview/pattern/html.d.ts +13 -10
  142. package/dist/transformers/preview/pattern/html.js +62 -27
  143. package/dist/transformers/preview/types.d.ts +38 -0
  144. package/dist/transformers/utils/handlebars.d.ts +3 -0
  145. package/dist/transformers/utils/handlebars.js +19 -5
  146. package/dist/types/config.d.ts +118 -25
  147. package/dist/utils/menu-shell.d.ts +60 -0
  148. package/dist/utils/menu-shell.js +221 -0
  149. package/dist/utils/stable-stringify.d.ts +2 -0
  150. package/dist/utils/stable-stringify.js +8 -0
  151. package/docs/cli.md +4 -2
  152. package/drizzle/0000_init_registry_storage.sql +95 -0
  153. package/drizzle/0001_add_review_metadata.sql +2 -0
  154. package/drizzle/0002_add_pages.sql +28 -0
  155. package/drizzle/0003_add_tokens.sql +29 -0
  156. package/drizzle/0004_add_assets.sql +37 -0
  157. package/drizzle/meta/0000_snapshot.json +642 -0
  158. package/drizzle/meta/0001_snapshot.json +654 -0
  159. package/drizzle/meta/0002_snapshot.json +835 -0
  160. package/drizzle/meta/0003_snapshot.json +1023 -0
  161. package/drizzle/meta/0004_snapshot.json +1252 -0
  162. package/drizzle/meta/_journal.json +41 -0
  163. package/drizzle.config.ts +16 -0
  164. package/package.json +10 -1
  165. package/src/app/components/Component/Preview.tsx +3 -2
  166. package/src/app/components/Component/RegistryActions.tsx +150 -0
  167. package/src/app/components/Foundations/DisplayLogo.tsx +5 -3
  168. package/src/app/components/Layout/Header.tsx +21 -3
  169. package/src/app/components/Layout/Main.tsx +11 -3
  170. package/src/app/components/Navigation/MainNav.tsx +4 -2
  171. package/src/app/components/Navigation/MobileNav.tsx +4 -2
  172. package/src/app/components/Navigation/SideNav.tsx +46 -11
  173. package/src/app/components/NotFound.tsx +24 -0
  174. package/src/app/components/context/NavProvider.tsx +71 -0
  175. package/src/app/components/old/Header.tsx +3 -3
  176. package/src/app/components/util/index.ts +203 -380
  177. package/src/app/components/util/useCollectionAssets.ts +125 -0
  178. package/src/app/components/util/useFoundationTokens.ts +67 -0
  179. package/src/app/generated/default-pages.json +1 -0
  180. package/src/app/generated/nav-shell.json +1 -0
  181. package/src/app/lib/api/query.ts +6 -0
  182. package/src/app/lib/api/redact.ts +46 -0
  183. package/src/app/lib/asset-storage.ts +81 -0
  184. package/src/app/lib/docs-api/artifacts.ts +116 -0
  185. package/src/app/lib/docs-api/backend.ts +151 -0
  186. package/src/app/lib/docs-api/errors.ts +52 -0
  187. package/src/app/lib/docs-api/handler.ts +34 -0
  188. package/src/app/lib/docs-api/index.ts +7 -0
  189. package/src/app/lib/docs-api/records.ts +189 -0
  190. package/src/app/lib/docs-api/registry.ts +130 -0
  191. package/src/app/lib/docs-api/runtime-config.ts +173 -0
  192. package/src/app/lib/docs-api/serve.ts +37 -0
  193. package/src/app/lib/docs-api/token-detail.ts +19 -0
  194. package/src/app/lib/registry-api/allowlist.ts +163 -0
  195. package/src/app/lib/registry-api/asset-transfer.ts +363 -0
  196. package/src/app/lib/registry-api/auth.ts +68 -0
  197. package/src/app/lib/registry-api/entity-routes.ts +197 -0
  198. package/src/app/lib/registry-api/errors.ts +51 -0
  199. package/src/app/lib/registry-api/files.ts +135 -0
  200. package/src/app/lib/registry-api/handler.ts +96 -0
  201. package/src/app/lib/registry-api/index.ts +4 -0
  202. package/src/app/lib/registry-api/meta.ts +87 -0
  203. package/src/app/lib/registry-api/revalidate.ts +36 -0
  204. package/src/app/lib/registry-api/store.ts +348 -0
  205. package/src/app/lib/registry-api/token-transfer.ts +231 -0
  206. package/src/app/lib/registry-api/transfer.ts +513 -0
  207. package/src/app/lib/registry-api/validation.ts +74 -0
  208. package/src/app/lib/registry-connection.ts +58 -0
  209. package/src/app/lib/utils.ts +14 -0
  210. package/src/app/next.config.mjs +72 -13
  211. package/src/app/pages/[...slug]/index.tsx +74 -20
  212. package/src/app/pages/_app.tsx +5 -2
  213. package/src/app/pages/api/docs/artifacts/[...artifactPath].ts +16 -0
  214. package/src/app/pages/api/docs/assets/[collection]/[...path].ts +50 -0
  215. package/src/app/pages/api/docs/assets/[collection]/index.ts +22 -0
  216. package/src/app/pages/api/docs/components/[id]/assets/[asset].ts +33 -0
  217. package/src/app/pages/api/docs/components/[id]/inspect/[previewId].ts +20 -0
  218. package/src/app/pages/api/docs/components/[id]/previews/[previewId].ts +21 -0
  219. package/src/app/pages/api/docs/components/[id].ts +24 -0
  220. package/src/app/pages/api/docs/components.json.ts +12 -0
  221. package/src/app/pages/api/docs/nav.json.ts +48 -0
  222. package/src/app/pages/api/docs/patterns/[id]/preview.ts +19 -0
  223. package/src/app/pages/api/docs/patterns/[id].ts +24 -0
  224. package/src/app/pages/api/docs/patterns.json.ts +12 -0
  225. package/src/app/pages/api/docs/tokens/components/[id].ts +30 -0
  226. package/src/app/pages/api/docs/tokens/foundations/[type].ts +32 -0
  227. package/src/app/pages/api/registry/components/[id]/files/[...filePath].ts +19 -0
  228. package/src/app/pages/api/registry/components/[id]/files/index.ts +19 -0
  229. package/src/app/pages/api/registry/components/[id].ts +20 -0
  230. package/src/app/pages/api/registry/components/index.ts +19 -0
  231. package/src/app/pages/api/registry/health.ts +20 -0
  232. package/src/app/pages/api/registry/patterns/[id]/files/[...filePath].ts +19 -0
  233. package/src/app/pages/api/registry/patterns/[id]/files/index.ts +19 -0
  234. package/src/app/pages/api/registry/patterns/[id].ts +20 -0
  235. package/src/app/pages/api/registry/patterns/index.ts +19 -0
  236. package/src/app/pages/api/registry/transfer/[kind]/[id].ts +39 -0
  237. package/src/app/pages/api/registry/transfer/assets/[collection].ts +24 -0
  238. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +23 -0
  239. package/src/app/pages/api/registry/transfer/assets/blobs/have.ts +11 -0
  240. package/src/app/pages/api/registry/transfer/assets/index.ts +10 -0
  241. package/src/app/pages/api/registry/transfer/tokens/[...setId].ts +26 -0
  242. package/src/app/pages/api/registry/transfer/tokens/index.ts +11 -0
  243. package/src/app/pages/assets/fonts.tsx +41 -17
  244. package/src/app/pages/assets/index.tsx +2 -2
  245. package/src/app/pages/assets/logos.tsx +2 -2
  246. package/src/app/pages/foundations/colors.tsx +18 -8
  247. package/src/app/pages/foundations/effects.tsx +18 -8
  248. package/src/app/pages/foundations/icons/[name]/index.tsx +26 -14
  249. package/src/app/pages/foundations/icons/index.tsx +13 -9
  250. package/src/app/pages/foundations/index.tsx +1 -1
  251. package/src/app/pages/foundations/logo.tsx +7 -4
  252. package/src/app/pages/foundations/typography.tsx +19 -9
  253. package/src/app/pages/index.tsx +1 -1
  254. package/src/app/pages/system/component/[component]/index.tsx +87 -17
  255. package/src/app/pages/system/index.tsx +13 -4
  256. package/src/app/pages/system/pattern/[pattern]/index.tsx +70 -14
  257. package/src/app/pages/system/tokens/components/[component]/index.tsx +42 -14
  258. package/src/app/pages/system/tokens/foundations/colors.tsx +7 -5
  259. package/src/app/pages/system/tokens/foundations/effects.tsx +7 -5
  260. package/src/app/pages/system/tokens/foundations/typography.tsx +8 -4
  261. package/src/app/tsconfig.json +2 -3
  262. package/src/app-builder/build.ts +555 -11
  263. package/src/app-builder/client-config.ts +61 -4
  264. package/src/app-builder/config-diff/snapshot.ts +1 -11
  265. package/src/app-builder/index.ts +1 -1
  266. package/src/app-builder/nav-shell.ts +74 -0
  267. package/src/app-builder/static-export.ts +215 -0
  268. package/src/app-builder/vercel-output.ts +297 -0
  269. package/src/app-builder/watchers/utils.ts +2 -0
  270. package/src/app-builder/watchers.ts +149 -25
  271. package/src/app-builder/websocket.ts +2 -2
  272. package/src/artifacts/README.md +14 -0
  273. package/src/artifacts/index.ts +17 -0
  274. package/src/artifacts/types.ts +146 -0
  275. package/src/artifacts/url.ts +90 -0
  276. package/src/cache/build-cache.ts +47 -2
  277. package/src/commands/build/app.ts +1 -2
  278. package/src/commands/build/index.ts +61 -0
  279. package/src/commands/checkout/index.ts +66 -0
  280. package/src/commands/db/migrate.ts +25 -0
  281. package/src/commands/index.ts +8 -0
  282. package/src/commands/publish/index.ts +63 -0
  283. package/src/config/defaults.ts +15 -0
  284. package/src/config/loader.ts +10 -1
  285. package/src/config/normalizers/declaration.ts +3 -0
  286. package/src/config/normalizers/page.ts +45 -0
  287. package/src/config/normalizers/pattern.ts +4 -1
  288. package/src/config/runtime.ts +48 -2
  289. package/src/index.ts +126 -3
  290. package/src/nav/index.ts +94 -0
  291. package/src/nav/registry.ts +54 -0
  292. package/src/nav/resolver.ts +217 -0
  293. package/src/nav/workspace.ts +87 -0
  294. package/src/pipeline/styles.ts +4 -35
  295. package/src/pipeline/token-transformers.ts +97 -0
  296. package/src/registry/asset-storage/adapters/vercel-blob.ts +51 -0
  297. package/src/registry/asset-storage/define.ts +15 -0
  298. package/src/registry/asset-storage/resolve.ts +72 -0
  299. package/src/registry/asset-storage/types.ts +46 -0
  300. package/src/registry/assets/layout.ts +38 -0
  301. package/src/registry/assets/sets.ts +48 -0
  302. package/src/registry/assets/transfer.ts +50 -0
  303. package/src/registry/checkout/assets.ts +126 -0
  304. package/src/registry/checkout/index.ts +596 -0
  305. package/src/registry/checkout/tokens.ts +152 -0
  306. package/src/registry/client.ts +234 -0
  307. package/src/registry/connection.ts +50 -0
  308. package/src/registry/db/adapter.ts +74 -0
  309. package/src/registry/db/client.ts +88 -0
  310. package/src/registry/db/index.ts +35 -0
  311. package/src/registry/db/migrate.ts +56 -0
  312. package/src/registry/db/schema.ts +444 -0
  313. package/src/registry/path.ts +26 -0
  314. package/src/registry/publish/artifacts.ts +147 -0
  315. package/src/registry/publish/assets.ts +129 -0
  316. package/src/registry/publish/errors.ts +34 -0
  317. package/src/registry/publish/index.ts +128 -0
  318. package/src/registry/publish/package.ts +272 -0
  319. package/src/registry/publish/publish-build.ts +51 -0
  320. package/src/registry/publish/tokens.ts +119 -0
  321. package/src/registry/tokens/sets.ts +132 -0
  322. package/src/registry/tokens/transfer.ts +53 -0
  323. package/src/registry/transfer.ts +82 -0
  324. package/src/store/filesystem-assets.ts +152 -0
  325. package/src/store/filesystem.ts +237 -0
  326. package/src/store/index.ts +62 -0
  327. package/src/store/registry.ts +334 -0
  328. package/src/store/source-files.ts +155 -0
  329. package/src/store/types.ts +210 -0
  330. package/src/transformers/plugins/csf-render.ts +31 -7
  331. package/src/transformers/plugins/handlebars-previews.ts +22 -3
  332. package/src/transformers/plugins/ssr-render.ts +145 -57
  333. package/src/transformers/preview/component/api.ts +2 -1
  334. package/src/transformers/preview/component/artifacts.ts +10 -1
  335. package/src/transformers/preview/component/builder.ts +9 -2
  336. package/src/transformers/preview/component/css.ts +10 -1
  337. package/src/transformers/preview/component/javascript.ts +55 -20
  338. package/src/transformers/preview/component/shared-artifacts.ts +146 -0
  339. package/src/transformers/preview/component.ts +5 -2
  340. package/src/transformers/preview/pattern/builder.ts +33 -7
  341. package/src/transformers/preview/pattern/html.ts +71 -27
  342. package/src/transformers/preview/types.ts +42 -0
  343. package/src/transformers/utils/handlebars.ts +31 -5
  344. package/src/types/config.ts +123 -25
  345. package/src/utils/menu-shell.ts +241 -0
  346. package/src/utils/stable-stringify.ts +7 -0
  347. package/templates/default/package.json.tpl +1 -1
  348. package/handoff.config.js +0 -90
  349. package/src/app/components/ComponentSearch.tsx +0 -93
@@ -6,6 +6,7 @@ import React from 'react';
6
6
  import ReactDOMServer from 'react-dom/server';
7
7
  import { Plugin, normalizePath } from 'vite';
8
8
  import Handoff from '../..';
9
+ import { buildArtifactUrl } from '../../artifacts/url';
9
10
  import { Logger } from '../../utils/logger';
10
11
  import {
11
12
  enrichPropertiesWithDocgen,
@@ -14,7 +15,15 @@ import {
14
15
  getPropertiesFromGeneratedDocs,
15
16
  } from '../docgen';
16
17
  import { SlotMetadata } from '../preview/component';
17
- import { MAIN_COMPONENT_CSS_FILE } from '../preview/component/css';
18
+ import {
19
+ renderComponentStyleLink,
20
+ renderGlobalScriptTag,
21
+ renderSharedStyleLinks,
22
+ resolveComponentArtifactPresence,
23
+ resolveSharedArtifactPresence,
24
+ type ComponentArtifactPresence,
25
+ type SharedArtifactPresence,
26
+ } from '../preview/component/shared-artifacts';
18
27
  import { TransformComponentTokensResult } from '../preview/types';
19
28
  import { DEFAULT_CLIENT_BUILD_CONFIG, createReactResolvePlugin } from '../utils/build';
20
29
  import { formatHtml, trimPreview } from '../utils/html';
@@ -41,6 +50,16 @@ const PLUGIN_CONSTANTS = {
41
50
  INSPECT_SUFFIX: '-inspect',
42
51
  } as const;
43
52
 
53
+ /**
54
+ * Suffix of the component-owned client/hydration artifact.
55
+ * One artifact per component (`component/<id>.client.js`) drives hydration for every preview of
56
+ * that component; it is referenced via the canonical artifact URL.
57
+ */
58
+ const CLIENT_ARTIFACT_SUFFIX = 'client.js';
59
+
60
+ /** Logical artifact path of a component's client/hydration bundle. */
61
+ const clientArtifactPath = (componentId: string): string => `component/${componentId}.${CLIENT_ARTIFACT_SUFFIX}`;
62
+
44
63
  /**
45
64
  * Loads and processes component schema using hierarchical approach
46
65
  * @param componentData - Component transformation data
@@ -95,43 +114,99 @@ async function loadComponentSchemaAndModule(
95
114
  }
96
115
 
97
116
  /**
98
- * Generates client-side hydration source code
117
+ * Generates client-side hydration source code for the component-owned client artifact.
118
+ *
119
+ * The bundle is shared by every preview of a component and is emitted once as
120
+ * `component/<id>.client.js`. It hydrates two shapes of mount point so the same artifact serves both
121
+ * a standalone preview document and a composed pattern document without per-fragment inline code:
122
+ *
123
+ * - The standalone preview's `#${ROOT_ELEMENT_ID}` element paired with the `#${PROPS_SCRIPT_ID}`
124
+ * props script (one mount per document).
125
+ * - Any number of pattern mount points marked `[data-handoff-component="<id>"]`, each carrying a
126
+ * `data-handoff-props` attribute naming its (namespaced) props script id. A pattern document
127
+ * references this artifact once per component and the bundle hydrates every matching mount.
128
+ *
129
+ * @param componentId - Component identifier used to match pattern mount points to this bundle
99
130
  * @param componentPath - Path to the component file
100
131
  * @returns Client-side hydration source code
101
132
  */
102
- function generateClientHydrationSource(componentPath: string): string {
133
+ function generateClientHydrationSource(componentId: string, componentPath: string): string {
103
134
  return `
104
135
  import React from 'react';
105
136
  import { hydrateRoot } from 'react-dom/client';
106
137
  import Component from '${normalizePath(componentPath)}';
107
138
 
108
- const raw = document.getElementById('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')?.textContent || '{}';
109
- const props = JSON.parse(raw);
110
- hydrateRoot(document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}'), <Component {...props} />);
139
+ const parseProps = (propsId) => {
140
+ const raw = propsId ? document.getElementById(propsId)?.textContent : '{}';
141
+ try {
142
+ return JSON.parse(raw || '{}');
143
+ } catch (_) {
144
+ return {};
145
+ }
146
+ };
147
+
148
+ const standaloneRoot = document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}');
149
+ if (standaloneRoot && !standaloneRoot.hasAttribute('data-handoff-component')) {
150
+ hydrateRoot(standaloneRoot, <Component {...parseProps('${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}')} />);
151
+ }
152
+
153
+ const patternMounts = document.querySelectorAll('[data-handoff-component=' + JSON.stringify('${componentId}') + ']');
154
+ patternMounts.forEach((mount) => {
155
+ hydrateRoot(mount, <Component {...parseProps(mount.getAttribute('data-handoff-props'))} />);
156
+ });
111
157
  `;
112
158
  }
113
159
 
114
160
  /**
115
- * Generates complete HTML document with SSR content and hydration
161
+ * Generates complete HTML document with SSR content and a reference to the component-owned client
162
+ * artifact. The preview HTML carries only document structure, server-rendered markup, preview data,
163
+ * stylesheets, and standard artifact references. Interactive React previews treat the client
164
+ * artifact as a required reference, so the script tag surfaces a clear, visible failure if the
165
+ * artifact cannot be loaded.
116
166
  * @param componentId - Component identifier
117
167
  * @param previewTitle - Title for the preview
118
168
  * @param renderedHtml - Server-rendered HTML content
119
- * @param clientJs - Client-side JavaScript bundle
120
169
  * @param props - Component props as JSON
121
170
  * @returns Complete HTML document
122
171
  */
123
- function generateHtmlDocument(componentId: string, previewTitle: string, renderedHtml: string, clientJs: string, props: any): string {
172
+ function generateHtmlDocument(
173
+ componentId: string,
174
+ previewTitle: string,
175
+ renderedHtml: string,
176
+ props: any,
177
+ sharedArtifacts: SharedArtifactPresence,
178
+ componentArtifacts: ComponentArtifactPresence
179
+ ): string {
180
+ const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
181
+ const clientArtifactUrl = buildArtifactUrl(clientArtifactPath(componentId), basePath);
182
+ const clientLoadErrorMessage = `Failed to load the React client artifact (${clientArtifactPath(componentId)}). This preview cannot hydrate.`;
183
+ // The handler is JS embedded in a double-quoted HTML attribute, so any `"` (e.g. from
184
+ // JSON.stringify) and `&` must be entity-encoded or the attribute terminates early and
185
+ // breaks HTML parsing. Browsers decode the entities before the JS engine runs the handler.
186
+ const clientLoadErrorHandler = `document.getElementById('${PLUGIN_CONSTANTS.ROOT_ELEMENT_ID}').setAttribute('data-handoff-client-error', '1');console.error(${JSON.stringify(
187
+ clientLoadErrorMessage
188
+ )});`
189
+ .replace(/&/g, '&amp;')
190
+ .replace(/"/g, '&quot;');
191
+ // Shared/global stylesheets (`component/main.css`) and the component's own stylesheet
192
+ // (`component/<id>.css`) are emitted only when present so absent optional artifacts never produce a
193
+ // missing-file browser request.
194
+ const sharedStyleLinks = renderSharedStyleLinks(sharedArtifacts, basePath);
195
+ const componentStyleLink = renderComponentStyleLink(componentArtifacts, componentId, basePath);
196
+ // The global script (`component/main.js`) is a classic tag placed before the client module so it
197
+ // runs before dependent component-specific scripts when present.
198
+ const globalScriptTag = renderGlobalScriptTag(sharedArtifacts, basePath);
124
199
  return `<!DOCTYPE html>
125
200
  <html lang="en">
126
201
  <head>
127
- <meta charset="UTF-8" />
128
- <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${MAIN_COMPONENT_CSS_FILE}" />
129
- <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${componentId}.css" />
130
- <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/css/preview.css" />
131
- <script id="${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}" type="application/json">${JSON.stringify(props)}</script>
132
- <script type="module">
133
- ${clientJs}
134
- </script>
202
+ <meta charset="UTF-8" />${sharedStyleLinks ? `\n ${sharedStyleLinks}` : ''}${componentStyleLink ? `\n ${componentStyleLink}` : ''}
203
+ <link rel="stylesheet" href="${basePath}/assets/css/preview.css" />
204
+ <script id="${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}" type="application/json">${JSON.stringify(props)}</script>${globalScriptTag ? `\n ${globalScriptTag}` : ''}
205
+ <script
206
+ type="module"
207
+ src="${clientArtifactUrl}"
208
+ onerror="${clientLoadErrorHandler}"
209
+ ></script>
135
210
  <title>${previewTitle}</title>
136
211
  </head>
137
212
  <body>
@@ -225,8 +300,58 @@ export function ssrRenderPlugin(
225
300
  }
226
301
  }
227
302
 
303
+ // Build the component-owned client/hydration bundle once and emit it as a single
304
+ // `component/<id>.client.js` artifact. The hydration source only imports the component and
305
+ // reads props from the in-document `__APP_PROPS__` element, so it is identical across every
306
+ // preview of this component.
307
+ const clientHydrationSource = generateClientHydrationSource(componentId, componentPath);
308
+ const clientBuildConfig = {
309
+ ...DEFAULT_CLIENT_BUILD_CONFIG,
310
+ logLevel: 'silent' as const,
311
+ stdin: {
312
+ contents: clientHydrationSource,
313
+ resolveDir: process.cwd(),
314
+ loader: 'tsx' as const,
315
+ },
316
+ plugins: [createReactResolvePlugin(handoff.workingPath, handoff.modulePath)],
317
+ };
318
+
319
+ const finalClientBuildConfig = handoff.config?.hooks?.clientBuildConfig
320
+ ? handoff.config.hooks.clientBuildConfig(clientBuildConfig)
321
+ : clientBuildConfig;
322
+
323
+ let clientBundleJs: string;
324
+ try {
325
+ const bundledClient = await esbuild.build(finalClientBuildConfig);
326
+ if (bundledClient.warnings.length > 0) {
327
+ const messages = await esbuild.formatMessages(bundledClient.warnings, { kind: 'warning', color: true });
328
+ messages.forEach((msg) => Logger.warn(msg));
329
+ }
330
+ clientBundleJs = bundledClient.outputFiles[0].text;
331
+ } catch (error: any) {
332
+ // The client artifact is a required reference for interactive React previews. If it cannot be
333
+ // built, do not emit preview HTML that points at a missing artifact — fail clearly instead.
334
+ Logger.error(`Failed to build client bundle for ${componentId}`);
335
+ if (error.errors) {
336
+ const messages = await esbuild.formatMessages(error.errors, { kind: 'error', color: true });
337
+ messages.forEach((msg) => Logger.error(msg));
338
+ }
339
+ return;
340
+ }
341
+
342
+ this.emitFile({
343
+ type: 'asset',
344
+ fileName: `${componentId}.${CLIENT_ARTIFACT_SUFFIX}`,
345
+ source: clientBundleJs,
346
+ });
347
+
228
348
  let finalHtml = '';
229
349
 
350
+ // Resolve which shared/global and component-owned artifacts exist so generated HTML references
351
+ // them only when present. These are built before component HTML, so this reflects final state.
352
+ const sharedArtifacts = resolveSharedArtifactPresence(handoff);
353
+ const componentArtifacts = resolveComponentArtifactPresence(handoff, componentId);
354
+
230
355
  // Generate previews for each variation
231
356
  for (const previewKey in componentData.previews) {
232
357
  const previewProps = componentData.previews[previewKey].values;
@@ -235,50 +360,13 @@ export function ssrRenderPlugin(
235
360
  const serverRenderedHtml = ReactDOMServer.renderToString(React.createElement(ReactComponent, previewProps));
236
361
  const formattedHtml = await formatHtml(serverRenderedHtml);
237
362
 
238
- // Generate client-side hydration code
239
- const clientHydrationSource = generateClientHydrationSource(componentPath);
240
-
241
- // Build client-side bundle
242
- const clientBuildConfig = {
243
- ...DEFAULT_CLIENT_BUILD_CONFIG,
244
- logLevel: 'silent' as const,
245
- stdin: {
246
- contents: clientHydrationSource,
247
- resolveDir: process.cwd(),
248
- loader: 'tsx' as const,
249
- },
250
- plugins: [createReactResolvePlugin(handoff.workingPath, handoff.modulePath)],
251
- };
252
-
253
- // Apply user's client build config hook if provided
254
- const finalClientBuildConfig = handoff.config?.hooks?.clientBuildConfig
255
- ? handoff.config.hooks.clientBuildConfig(clientBuildConfig)
256
- : clientBuildConfig;
257
-
258
- let clientBundleJs: string;
259
- try {
260
- const bundledClient = await esbuild.build(finalClientBuildConfig);
261
- if (bundledClient.warnings.length > 0) {
262
- const messages = await esbuild.formatMessages(bundledClient.warnings, { kind: 'warning', color: true });
263
- messages.forEach((msg) => Logger.warn(msg));
264
- }
265
- clientBundleJs = bundledClient.outputFiles[0].text;
266
- } catch (error: any) {
267
- Logger.error(`Failed to build client bundle for ${componentId}`);
268
- if (error.errors) {
269
- const messages = await esbuild.formatMessages(error.errors, { kind: 'error', color: true });
270
- messages.forEach((msg) => Logger.error(msg));
271
- }
272
- continue;
273
- }
274
-
275
- // Generate complete HTML document
276
363
  finalHtml = generateHtmlDocument(
277
364
  componentId,
278
365
  componentData.previews[previewKey].title,
279
366
  formattedHtml,
280
- clientBundleJs,
281
- previewProps
367
+ previewProps,
368
+ sharedArtifacts,
369
+ componentArtifacts
282
370
  );
283
371
 
284
372
  // Emit preview files
@@ -1,5 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
+ import { buildComponentDetailUrl } from '../../../artifacts/url';
3
4
  import Handoff from '../../../index';
4
5
  import { ComponentListObject, TransformComponentTokensResult } from '../types';
5
6
  import { getDocumentedPreviews } from './previews';
@@ -148,7 +149,7 @@ export const readComponentMetadataApi = async (handoff: Handoff, id: string): Pr
148
149
  tags: componentData.tags ? componentData.tags : [],
149
150
  properties: componentData.properties,
150
151
  previews: getDocumentedPreviews(componentData.previews),
151
- path: `/api/component/${id}.json`,
152
+ path: buildComponentDetailUrl(id, process.env.HANDOFF_APP_BASE_PATH ?? ''),
152
153
  };
153
154
  };
154
155
  export default writeComponentApi;
@@ -10,6 +10,9 @@ import { MAIN_COMPONENT_JS_FILE } from './javascript';
10
10
  const COMPONENT_JS_RESERVED_FILES = new Set([MAIN_COMPONENT_JS_FILE]);
11
11
  const COMPONENT_CSS_RESERVED_FILES = new Set([MAIN_COMPONENT_CSS_FILE, SHARED_COMPONENT_CSS_FILE]);
12
12
 
13
+ /** Suffix of a component-owned client/hydration artifact (`<id>.client.js`), see ssr-render plugin. */
14
+ const CLIENT_ARTIFACT_SUFFIX = '.client.js';
15
+
13
16
  export const getComponentApiPath = (handoff: Handoff) => path.resolve(getAPIPath(handoff), 'component');
14
17
 
15
18
  const getComponentPreviewKeys = async (handoff: Handoff, componentId: string): Promise<Set<string>> => {
@@ -77,7 +80,9 @@ export const syncComponentArtifacts = async (handoff: Handoff): Promise<void> =>
77
80
  const parsed = path.parse(entry);
78
81
  if (parsed.ext === '.json' || parsed.ext === '.js' || parsed.ext === '.css') {
79
82
  if (!COMPONENT_JS_RESERVED_FILES.has(entry) && !COMPONENT_CSS_RESERVED_FILES.has(entry)) {
80
- discoveredArtifactIds.add(parsed.name);
83
+ // `<id>.client.js` is owned by `<id>`, not by a phantom `<id>.client` component — strip the
84
+ // compound suffix so the owner id matches the real component (path.parse would yield `<id>.client`).
85
+ discoveredArtifactIds.add(entry.endsWith(CLIENT_ARTIFACT_SUFFIX) ? entry.slice(0, -CLIENT_ARTIFACT_SUFFIX.length) : parsed.name);
81
86
  }
82
87
  }
83
88
  }
@@ -90,6 +95,10 @@ export const syncComponentArtifacts = async (handoff: Handoff): Promise<void> =>
90
95
  validFiles.add(`${componentId}.js`);
91
96
  }
92
97
 
98
+ if (await fs.pathExists(path.resolve(componentPath, `${componentId}${CLIENT_ARTIFACT_SUFFIX}`))) {
99
+ validFiles.add(`${componentId}${CLIENT_ARTIFACT_SUFFIX}`);
100
+ }
101
+
93
102
  if (await fs.pathExists(path.resolve(componentPath, `${componentId}.css`))) {
94
103
  validFiles.add(`${componentId}.css`);
95
104
  }
@@ -1,5 +1,6 @@
1
1
  import { Types as CoreTypes } from 'handoff-core';
2
2
  import cloneDeep from 'lodash/cloneDeep';
3
+ import { buildComponentDetailUrl } from '../../../artifacts/url';
3
4
  import {
4
5
  BuildCache,
5
6
  checkOutputExists,
@@ -178,7 +179,13 @@ export async function processComponents(
178
179
 
179
180
  const documentationObject = await handoff.getDocumentationObject();
180
181
  const components = documentationObject?.components ?? ({} as CoreTypes.IDocumentationObject['components']);
181
- const runtimeComponents = handoff.runtimeConfig?.entries?.components ?? {};
182
+ // Resolve the component set through the storage-agnostic store (v2). The filesystem store is a
183
+ // read view over `runtimeConfig.entries.components`, so these are the same records the build has
184
+ // always used — rebuilt into the id-keyed map the build logic below expects.
185
+ const runtimeComponents: Record<string, ComponentListObject> = {};
186
+ for (const component of await handoff.store.components.list()) {
187
+ if (component) runtimeComponents[component.id] = component;
188
+ }
182
189
  const allComponentIds = Object.keys(runtimeComponents);
183
190
 
184
191
  if (id && !runtimeComponents[id]) {
@@ -458,7 +465,7 @@ const buildComponentSummary = (id: string, data: TransformComponentTokensResult)
458
465
  tags: data.tags ? data.tags : [],
459
466
  properties: data.properties,
460
467
  previews: getDocumentedPreviews(data.previews),
461
- path: `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${id}.json`,
468
+ path: buildComponentDetailUrl(id, process.env.HANDOFF_APP_BASE_PATH ?? ''),
462
469
  };
463
470
  };
464
471
 
@@ -183,6 +183,7 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
183
183
  */
184
184
  export const buildMainCss = async (handoff: Handoff): Promise<void> => {
185
185
  const outputPath = getComponentOutputPath(handoff);
186
+ const mainCssPath = path.resolve(outputPath, MAIN_COMPONENT_CSS_FILE);
186
187
  const runtimeConfig = initRuntimeConfig(handoff)[0];
187
188
 
188
189
  if (runtimeConfig?.entries?.scss && fs.existsSync(runtimeConfig.entries.scss)) {
@@ -192,6 +193,9 @@ export const buildMainCss = async (handoff: Handoff): Promise<void> => {
192
193
  if (entryPath === runtimeConfig.entries.scss || fs.existsSync(entryPath)) {
193
194
  Logger.info(`Building styles for global entry (${MAIN_COMPONENT_CSS_FILE})…`);
194
195
  const startedAt = Date.now();
196
+ // Drop the previous artifact so a failed/empty rebuild cannot preserve stale global CSS that
197
+ // would still be referenced as current.
198
+ await fs.remove(mainCssPath);
195
199
  try {
196
200
  const loadPaths = [
197
201
  path.resolve(handoff.workingPath),
@@ -212,10 +216,15 @@ export const buildMainCss = async (handoff: Handoff): Promise<void> => {
212
216
  });
213
217
  Logger.info(`Finished building styles for global entry (${MAIN_COMPONENT_CSS_FILE}) in ${formatDurationMs(Date.now() - startedAt)}`);
214
218
  } catch {
215
- // buildCssBundle already logs failure for the entry path
219
+ // buildCssBundle already logs failure for the entry path; ensure no stale artifact remains.
220
+ await fs.remove(mainCssPath);
216
221
  }
217
222
  }
223
+ return;
218
224
  }
225
+
226
+ // No usable global SCSS entry: drop any stale artifact so it is not referenced as current.
227
+ await fs.remove(mainCssPath);
219
228
  };
220
229
 
221
230
  export default buildComponentCss;
@@ -20,7 +20,12 @@ export const MAIN_COMPONENT_JS_FILE = 'main.js';
20
20
  * @param options.outputFilename - The name of the output file
21
21
  */
22
22
  const buildJsBundle = async (
23
- { entry, outputPath, outputFilename }: { entry: string; outputPath: string; outputFilename: string },
23
+ {
24
+ entry,
25
+ outputPath,
26
+ outputFilename,
27
+ format = 'cjs',
28
+ }: { entry: string; outputPath: string; outputFilename: string; format?: 'cjs' | 'iife' },
24
29
  handoff: Handoff
25
30
  ) => {
26
31
  const absEntryPath = path.resolve(entry);
@@ -39,13 +44,15 @@ const buildJsBundle = async (
39
44
  lib: {
40
45
  entry: absEntryPath,
41
46
  name: path.basename(outputFilename, '.js'),
42
- formats: ['cjs'],
47
+ formats: [format],
43
48
  fileName: () => outputFilename,
44
49
  },
45
50
  rollupOptions: {
46
51
  ...viteBaseConfig.build?.rollupOptions,
47
52
  output: {
48
- exports: 'named',
53
+ // The global IIFE bundle is frequently side-effect-only; `auto` lets a bundle with no
54
+ // exports emit cleanly while `named` preserves the existing component-JS (cjs) behavior.
55
+ exports: format === 'iife' ? 'auto' : 'named',
49
56
  },
50
57
  },
51
58
  outDir: outputPath,
@@ -127,34 +134,62 @@ export const buildComponentJs = async (data: TransformComponentTokensResult, han
127
134
  };
128
135
 
129
136
  /**
130
- * Builds the main JavaScript bundle for the component preview.
137
+ * Builds the global preview script artifact (`component/main.js`) from the configured workspace JS
138
+ * entry.
131
139
  *
132
- * This function checks if there's a main JavaScript bundle defined in the runtime config,
133
- * and if the file exists, it builds the bundle and outputs it to the component's output path.
140
+ * The artifact is optional: when no global JS entry is configured, or the configured entry no longer
141
+ * exists, or the build fails/produces no output, any stale `main.js` is removed so a failed rebuild
142
+ * never leaves global JS that appears current. Generated HTML references `main.js` only when this
143
+ * function leaves a fresh artifact on disk, so an omitted artifact yields no dangling reference.
144
+ *
145
+ * Output uses the browser-compatible IIFE format so the artifact can load as a classic script ahead
146
+ * of dependent component scripts while preserving intentional top-level side effects.
134
147
  *
135
148
  * @param handoff - The Handoff configuration object containing build settings
136
149
  * @returns A Promise that resolves when the build process is complete
137
150
  */
138
151
  export const buildMainJS = async (handoff: Handoff): Promise<void> => {
139
152
  const outputPath = getComponentOutputPath(handoff);
153
+ const mainJsPath = path.resolve(outputPath, MAIN_COMPONENT_JS_FILE);
140
154
  const runtimeConfig = initRuntimeConfig(handoff)[0];
155
+ const entry = runtimeConfig?.entries?.js;
141
156
 
142
- if (runtimeConfig && runtimeConfig.entries.js && fs.existsSync(path.resolve(runtimeConfig.entries.js))) {
143
- Logger.info(`Building script for global entry (${MAIN_COMPONENT_JS_FILE})…`);
144
- const startedAt = Date.now();
145
- try {
146
- await buildJsBundle(
147
- {
148
- entry: runtimeConfig.entries.js,
149
- outputPath,
150
- outputFilename: MAIN_COMPONENT_JS_FILE,
151
- },
152
- handoff
153
- );
157
+ // No usable global JS entry: drop any stale artifact and emit nothing to reference.
158
+ if (!entry || !fs.existsSync(path.resolve(entry))) {
159
+ if (entry) {
160
+ Logger.warn(`Global JS entry "${entry}" was not found; removing stale ${MAIN_COMPONENT_JS_FILE}.`);
161
+ }
162
+ await fs.remove(mainJsPath);
163
+ return;
164
+ }
165
+
166
+ Logger.info(`Building script for global entry (${MAIN_COMPONENT_JS_FILE})…`);
167
+ const startedAt = Date.now();
168
+
169
+ // Remove the previous artifact before rebuilding so a failed or empty build cannot silently
170
+ // preserve stale global JS that would still be referenced as current.
171
+ await fs.remove(mainJsPath);
172
+
173
+ try {
174
+ await buildJsBundle(
175
+ {
176
+ entry,
177
+ outputPath,
178
+ outputFilename: MAIN_COMPONENT_JS_FILE,
179
+ format: 'iife',
180
+ },
181
+ handoff
182
+ );
183
+
184
+ if (await fs.pathExists(mainJsPath)) {
154
185
  Logger.info(`Finished building script for global entry (${MAIN_COMPONENT_JS_FILE}) in ${formatDurationMs(Date.now() - startedAt)}`);
155
- } catch (e) {
156
- Logger.error(`Failed to build global script (${MAIN_COMPONENT_JS_FILE}):`, e);
186
+ } else {
187
+ Logger.error(`Global script build produced no ${MAIN_COMPONENT_JS_FILE} from "${entry}".`);
157
188
  }
189
+ } catch (e) {
190
+ Logger.error(`Failed to build global script (${MAIN_COMPONENT_JS_FILE}) from "${entry}":`, e);
191
+ // Ensure a partial/failed build leaves no stale or broken artifact behind.
192
+ await fs.remove(mainJsPath);
158
193
  }
159
194
  };
160
195
 
@@ -0,0 +1,146 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import {
4
+ ArtifactReference,
5
+ SHARED_MAIN_CSS_ARTIFACT_PATH,
6
+ SHARED_MAIN_JS_ARTIFACT_PATH,
7
+ SHARED_STYLES_CSS_ARTIFACT_PATH,
8
+ } from '../../../artifacts';
9
+ import { buildArtifactUrl } from '../../../artifacts/url';
10
+ import Handoff from '../../../index';
11
+ import { getComponentOutputPath } from '../component';
12
+ import { MAIN_COMPONENT_CSS_FILE, SHARED_COMPONENT_CSS_FILE } from './css';
13
+ import { MAIN_COMPONENT_JS_FILE } from './javascript';
14
+
15
+ /**
16
+ * Centralized shared/global artifact model for generated preview HTML.
17
+ *
18
+ * The shared artifacts (`component/main.css`, `component/main.js`, `component/shared.css`) are
19
+ * optional: each is emitted only when a corresponding source entry is configured and builds
20
+ * successfully. Generated HTML must reference a shared artifact only when it actually exists, so a
21
+ * failed or absent global build never leaves a dangling reference the browser would request.
22
+ *
23
+ * This module is the single place that decides which shared artifacts are present and turns that
24
+ * decision into structured references and the `<link>`/`<script>` tags every renderer emits — so
25
+ * dependency resolution is driven by the artifact model, never by parsing HTML.
26
+ */
27
+
28
+ /** Which shared/global artifacts currently exist in the component output directory. */
29
+ export interface SharedArtifactPresence {
30
+ /** `component/main.js` — the global preview script built from the configured JS entry. */
31
+ mainJs: boolean;
32
+ /** `component/main.css` — the global stylesheet built from the configured SCSS entry. */
33
+ mainCss: boolean;
34
+ /** `component/shared.css` — shared component styles extracted by the SCSS split marker. */
35
+ sharedCss: boolean;
36
+ }
37
+
38
+ /**
39
+ * Resolve which shared/global artifacts are present on disk. Global artifacts are built before
40
+ * component/pattern HTML is generated, so an existence check here reflects whether the artifact can
41
+ * be referenced without producing a missing-file browser request.
42
+ */
43
+ export const resolveSharedArtifactPresence = (handoff: Handoff): SharedArtifactPresence => {
44
+ const outputPath = getComponentOutputPath(handoff);
45
+ return {
46
+ mainJs: fs.existsSync(path.join(outputPath, MAIN_COMPONENT_JS_FILE)),
47
+ mainCss: fs.existsSync(path.join(outputPath, MAIN_COMPONENT_CSS_FILE)),
48
+ sharedCss: fs.existsSync(path.join(outputPath, SHARED_COMPONENT_CSS_FILE)),
49
+ };
50
+ };
51
+
52
+ /** Options controlling which shared style references a renderer participates in. */
53
+ export interface SharedStyleOptions {
54
+ /**
55
+ * Whether the rendered component opts into the shared-styles split (`component/shared.css`).
56
+ * Only renderers that consume shared styles (Handlebars) set this; it is still gated on the
57
+ * artifact actually existing.
58
+ */
59
+ includeSharedStyles?: boolean;
60
+ }
61
+
62
+ /**
63
+ * Build the structured shared-artifact references a preview/inspect document depends on, filtered to
64
+ * the artifacts that actually exist. Shared/global artifacts are optional references (`required:
65
+ * false`) owned by no entity (`ownerKind: 'asset'`, `ownerId: null`). Stylesheet references precede
66
+ * the script reference so consumers preserve the documented load order.
67
+ */
68
+ export const getSharedArtifactReferences = (
69
+ presence: SharedArtifactPresence,
70
+ options?: SharedStyleOptions
71
+ ): ArtifactReference[] => {
72
+ const references: ArtifactReference[] = [];
73
+
74
+ if (options?.includeSharedStyles && presence.sharedCss) {
75
+ references.push({ path: SHARED_STYLES_CSS_ARTIFACT_PATH, kind: 'shared', required: false, ownerKind: 'asset', ownerId: null });
76
+ }
77
+ if (presence.mainCss) {
78
+ references.push({ path: SHARED_MAIN_CSS_ARTIFACT_PATH, kind: 'shared', required: false, ownerKind: 'asset', ownerId: null });
79
+ }
80
+ if (presence.mainJs) {
81
+ references.push({ path: SHARED_MAIN_JS_ARTIFACT_PATH, kind: 'shared', required: false, ownerKind: 'asset', ownerId: null });
82
+ }
83
+
84
+ return references;
85
+ };
86
+
87
+ /**
88
+ * Render the `<link rel="stylesheet">` tags for the present shared stylesheet artifacts
89
+ * (`component/shared.css` then `component/main.css`), in load order. Returns the tags joined by a
90
+ * newline, or an empty string when no shared stylesheet exists.
91
+ */
92
+ export const renderSharedStyleLinks = (
93
+ presence: SharedArtifactPresence,
94
+ basePath: string,
95
+ options?: SharedStyleOptions
96
+ ): string =>
97
+ getSharedArtifactReferences(presence, options)
98
+ .filter((ref) => ref.path.endsWith('.css'))
99
+ .map((ref) => `<link rel="stylesheet" href="${buildArtifactUrl(ref.path, basePath)}">`)
100
+ .join('\n');
101
+
102
+ /**
103
+ * Render the global preview script tag for `component/main.js` when present, or an empty string when
104
+ * absent. The global script is a classic (non-deferred) tag so it executes before dependent
105
+ * component-specific scripts and preserves intentional top-level side effects.
106
+ */
107
+ export const renderGlobalScriptTag = (presence: SharedArtifactPresence, basePath: string): string =>
108
+ presence.mainJs ? `<script src="${buildArtifactUrl(SHARED_MAIN_JS_ARTIFACT_PATH, basePath)}"></script>` : '';
109
+
110
+ /** Which of a component's own optional artifacts exist in the component output directory. */
111
+ export interface ComponentArtifactPresence {
112
+ /** `component/<id>.css` — built only when the component declares an SCSS/CSS entry. */
113
+ css: boolean;
114
+ /** `component/<id>.js` — built only when the component declares a JS entry. */
115
+ js: boolean;
116
+ }
117
+
118
+ /**
119
+ * Resolve which of a component's own css/js artifacts exist on disk. A component's styles and
120
+ * scripts are built (or removed when their entry is absent) before its preview HTML is generated, so
121
+ * an existence check here reflects whether the artifact can be referenced without producing a
122
+ * missing-file browser request. Like the shared artifacts, these references are optional.
123
+ */
124
+ export const resolveComponentArtifactPresence = (handoff: Handoff, componentId: string): ComponentArtifactPresence => {
125
+ const outputPath = getComponentOutputPath(handoff);
126
+ return {
127
+ css: fs.existsSync(path.join(outputPath, `${componentId}.css`)),
128
+ js: fs.existsSync(path.join(outputPath, `${componentId}.js`)),
129
+ };
130
+ };
131
+
132
+ /**
133
+ * Render the `<link rel="stylesheet">` tag for a component's own stylesheet (`component/<id>.css`)
134
+ * when it exists, or an empty string when the component declares no styles — so generated HTML never
135
+ * references an absent optional artifact.
136
+ */
137
+ export const renderComponentStyleLink = (presence: ComponentArtifactPresence, componentId: string, basePath: string): string =>
138
+ presence.css ? `<link rel="stylesheet" href="${buildArtifactUrl(`component/${componentId}.css`, basePath)}">` : '';
139
+
140
+ /**
141
+ * Render the `<script>` tag for a component's own script (`component/<id>.js`) when it exists, or an
142
+ * empty string when the component declares no script. The component script is a classic tag loaded
143
+ * after the global script so global side effects run first.
144
+ */
145
+ export const renderComponentScriptTag = (presence: ComponentArtifactPresence, componentId: string, basePath: string): string =>
146
+ presence.js ? `<script src="${buildArtifactUrl(`component/${componentId}.js`, basePath)}"></script>` : '';
@@ -86,8 +86,11 @@ export const getComponentOutputPath = (handoff: Handoff) => path.resolve(getAPIP
86
86
  * @returns
87
87
  */
88
88
  export async function componentTransformer(handoff: Handoff) {
89
- const componentData = await processComponents(handoff);
90
- await writeComponentSummaryAPI(handoff, componentData);
89
+ // Build the shared/global artifacts first so component/pattern preview HTML can reference them
90
+ // only when they actually exist. A failed/absent global build therefore never produces a dangling
91
+ // reference in generated HTML.
91
92
  await buildMainJS(handoff);
92
93
  await buildMainCss(handoff);
94
+ const componentData = await processComponents(handoff);
95
+ await writeComponentSummaryAPI(handoff, componentData);
93
96
  }