handoff-app 2.0.0-5 → 2.0.0-7

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 (201) hide show
  1. package/README.md +174 -41
  2. package/UPGRADE.md +198 -0
  3. package/config/config.template.js +1 -4
  4. package/config/config.template.ts +1 -2
  5. package/dist/app-builder/build.js +11 -6
  6. package/dist/app-builder/client-config.js +1 -0
  7. package/dist/app-builder/config-diff/strategies/pattern.js +3 -3
  8. package/dist/app-builder/watchers/component.js +5 -5
  9. package/dist/app-builder/watchers/utils.d.ts +2 -3
  10. package/dist/app-builder/watchers.d.ts +8 -15
  11. package/dist/app-builder/watchers.js +53 -54
  12. package/dist/cache/build-cache.js +18 -22
  13. package/dist/catalog/define.d.ts +6 -0
  14. package/dist/catalog/define.js +72 -0
  15. package/dist/catalog/implementation.d.ts +27 -0
  16. package/dist/catalog/implementation.js +133 -0
  17. package/dist/catalog/index.d.ts +6 -0
  18. package/dist/catalog/index.js +22 -0
  19. package/dist/catalog/normalize.d.ts +16 -0
  20. package/dist/catalog/normalize.js +115 -0
  21. package/dist/catalog/previews.d.ts +18 -0
  22. package/dist/catalog/previews.js +106 -0
  23. package/dist/catalog/renderers.d.ts +43 -0
  24. package/dist/catalog/renderers.js +62 -0
  25. package/dist/catalog/types.d.ts +63 -0
  26. package/dist/cli/make.d.ts +0 -5
  27. package/dist/cli/make.js +22 -67
  28. package/dist/cli/scaffold.js +25 -33
  29. package/dist/commands/checkout/index.d.ts +7 -4
  30. package/dist/commands/checkout/index.js +9 -6
  31. package/dist/commands/compat/push-args.d.ts +2 -2
  32. package/dist/commands/index.js +0 -2
  33. package/dist/commands/publish/index.d.ts +9 -6
  34. package/dist/commands/publish/index.js +11 -8
  35. package/dist/commands/pull/index.js +1 -1
  36. package/dist/commands/push/index.js +1 -1
  37. package/dist/commands/utils.d.ts +15 -4
  38. package/dist/commands/utils.js +19 -6
  39. package/dist/config/catalog-include.d.ts +43 -0
  40. package/dist/config/catalog-include.js +203 -0
  41. package/dist/config/defaults.d.ts +2 -0
  42. package/dist/config/defaults.js +4 -1
  43. package/dist/config/index.d.ts +3 -3
  44. package/dist/config/index.js +6 -5
  45. package/dist/config/loader.js +9 -1
  46. package/dist/config/runtime.d.ts +5 -6
  47. package/dist/config/runtime.js +154 -154
  48. package/dist/handlebars.d.ts +9 -0
  49. package/dist/handlebars.js +16 -0
  50. package/dist/index.d.ts +15 -4
  51. package/dist/index.js +29 -15
  52. package/dist/pattern.d.ts +7 -0
  53. package/dist/pattern.js +13 -0
  54. package/dist/react.d.ts +43 -0
  55. package/dist/react.js +11 -0
  56. package/dist/registry/asset-storage/define.d.ts +1 -1
  57. package/dist/registry/asset-storage/define.js +1 -1
  58. package/dist/registry/checkout/index.d.ts +9 -0
  59. package/dist/registry/checkout/index.js +335 -149
  60. package/dist/registry/db/schema.d.ts +34 -0
  61. package/dist/registry/db/schema.js +2 -0
  62. package/dist/registry/publish/index.d.ts +13 -2
  63. package/dist/registry/publish/index.js +109 -25
  64. package/dist/registry/publish/package.js +14 -1
  65. package/dist/registry/selection.d.ts +20 -0
  66. package/dist/registry/selection.js +37 -1
  67. package/dist/store/filesystem.d.ts +1 -1
  68. package/dist/store/filesystem.js +7 -7
  69. package/dist/store/registry.js +24 -3
  70. package/dist/transformers/plugins/csf-render.js +4 -2
  71. package/dist/transformers/plugins/ssr-render.js +14 -5
  72. package/dist/transformers/preview/component/api.d.ts +8 -0
  73. package/dist/transformers/preview/component/api.js +11 -15
  74. package/dist/transformers/preview/component/artifacts.d.ts +1 -1
  75. package/dist/transformers/preview/component/artifacts.js +11 -7
  76. package/dist/transformers/preview/component/builder.js +15 -18
  77. package/dist/transformers/preview/component/css.d.ts +2 -0
  78. package/dist/transformers/preview/component/css.js +8 -11
  79. package/dist/transformers/preview/component/html.js +18 -16
  80. package/dist/transformers/preview/component/javascript.js +2 -4
  81. package/dist/transformers/preview/pattern/builder.js +31 -18
  82. package/dist/transformers/preview/types.d.ts +15 -1
  83. package/dist/types/config.d.ts +36 -11
  84. package/docs/api.md +3 -0
  85. package/docs/cli.md +1 -1
  86. package/drizzle/0006_add_component_source_format.sql +3 -0
  87. package/drizzle/meta/0006_snapshot.json +1941 -0
  88. package/drizzle/meta/_journal.json +8 -1
  89. package/package.json +37 -2
  90. package/src/app/components/Component/Preview.tsx +36 -50
  91. package/src/app/components/CopyToClipboard.tsx +27 -26
  92. package/src/app/components/Layout/Header.tsx +4 -12
  93. package/src/app/components/Layout/RuntimeModeBadge.tsx +18 -0
  94. package/src/app/components/Markdown/CodeHighlight.tsx +73 -70
  95. package/src/app/components/McpIntegration/McpConfigDialog.tsx +349 -0
  96. package/src/app/components/McpIntegration/McpIcon.tsx +10 -0
  97. package/src/app/components/Navigation/MobileNav.tsx +3 -1
  98. package/src/app/components/context/PreviewContext.tsx +5 -5
  99. package/src/app/components/ui/dialog.tsx +73 -0
  100. package/src/app/components/util/index.ts +7 -15
  101. package/src/app/lib/docs-api/backend.ts +5 -1
  102. package/src/app/lib/docs-api/page-discovery.ts +1 -6
  103. package/src/app/lib/docs-api/page-rendering.ts +21 -1
  104. package/src/app/lib/docs-api/records.ts +25 -10
  105. package/src/app/lib/docs-api/registry.ts +4 -2
  106. package/src/app/lib/docs-api/runtime-config.ts +5 -0
  107. package/src/app/lib/docs-api/search.ts +7 -2
  108. package/src/app/lib/mcp/auth.ts +64 -0
  109. package/src/app/lib/mcp/index.ts +2 -0
  110. package/src/app/lib/mcp/server.ts +343 -0
  111. package/src/app/lib/mcp/shape.ts +596 -0
  112. package/src/app/lib/registry-api/transfer.ts +7 -5
  113. package/src/app/next.config.mjs +3 -0
  114. package/src/app/pages/[...slug]/index.tsx +16 -30
  115. package/src/app/pages/api/mcp.ts +56 -0
  116. package/src/app/pages/index.tsx +10 -9
  117. package/src/app-builder/build.ts +12 -8
  118. package/src/app-builder/client-config.ts +2 -1
  119. package/src/app-builder/config-diff/strategies/pattern.ts +1 -1
  120. package/src/app-builder/watchers/component.ts +3 -3
  121. package/src/app-builder/watchers/utils.ts +2 -3
  122. package/src/app-builder/watchers.ts +46 -50
  123. package/src/cache/build-cache.ts +11 -14
  124. package/src/catalog/README.md +92 -0
  125. package/src/catalog/define.ts +79 -0
  126. package/src/catalog/implementation.ts +145 -0
  127. package/src/catalog/index.ts +31 -0
  128. package/src/catalog/normalize.ts +143 -0
  129. package/src/catalog/previews.ts +111 -0
  130. package/src/catalog/renderers.ts +94 -0
  131. package/src/catalog/types.ts +74 -0
  132. package/src/cli/README.md +1 -1
  133. package/src/cli/make.ts +20 -72
  134. package/src/cli/scaffold.ts +25 -31
  135. package/src/commands/README.md +1 -1
  136. package/src/commands/checkout/index.ts +17 -7
  137. package/src/commands/compat/push-args.ts +2 -2
  138. package/src/commands/index.ts +0 -2
  139. package/src/commands/publish/index.ts +19 -9
  140. package/src/commands/pull/index.ts +2 -2
  141. package/src/commands/push/index.ts +2 -2
  142. package/src/commands/utils.ts +22 -7
  143. package/src/config/README.md +2 -1
  144. package/src/config/catalog-include.ts +227 -0
  145. package/src/config/defaults.ts +3 -0
  146. package/src/config/index.ts +4 -4
  147. package/src/config/loader.ts +12 -0
  148. package/src/config/runtime.ts +186 -191
  149. package/src/handlebars.ts +18 -0
  150. package/src/index.ts +39 -26
  151. package/src/pattern.ts +14 -0
  152. package/src/react.ts +52 -0
  153. package/src/registry/asset-storage/define.ts +1 -1
  154. package/src/registry/checkout/index.ts +416 -152
  155. package/src/registry/db/schema.ts +2 -0
  156. package/src/registry/publish/index.ts +123 -26
  157. package/src/registry/publish/package.ts +14 -1
  158. package/src/registry/selection.ts +44 -0
  159. package/src/store/filesystem.ts +4 -4
  160. package/src/store/registry.ts +13 -2
  161. package/src/transformers/plugins/csf-render.ts +4 -2
  162. package/src/transformers/plugins/ssr-render.ts +15 -5
  163. package/src/transformers/preview/component/api.ts +13 -1
  164. package/src/transformers/preview/component/artifacts.ts +8 -4
  165. package/src/transformers/preview/component/builder.ts +17 -4
  166. package/src/transformers/preview/component/css.ts +6 -10
  167. package/src/transformers/preview/component/html.ts +30 -19
  168. package/src/transformers/preview/component/javascript.ts +1 -3
  169. package/src/transformers/preview/pattern/builder.ts +33 -20
  170. package/src/transformers/preview/types.ts +15 -1
  171. package/src/types/config.ts +38 -11
  172. package/templates/default/README.md.tpl +10 -0
  173. package/templates/default/components/button/button.react.handoff.js.tpl +19 -23
  174. package/templates/default/components/button/button.react.handoff.ts.tpl +22 -22
  175. package/templates/default/handoff.config.js.tpl +11 -18
  176. package/templates/default/handoff.config.ts.tpl +11 -18
  177. package/templates/default/handoff.config.with-examples.js.tpl +12 -16
  178. package/templates/default/handoff.config.with-examples.ts.tpl +12 -16
  179. package/config/templates/component/template.json +0 -49
  180. package/config/templates/template.html +0 -7
  181. package/dist/commands/make/template.d.ts +0 -8
  182. package/dist/commands/make/template.js +0 -43
  183. package/dist/config/entries.d.ts +0 -47
  184. package/dist/config/entries.js +0 -162
  185. package/dist/config/normalizers/declaration.d.ts +0 -11
  186. package/dist/config/normalizers/declaration.js +0 -122
  187. package/dist/config/normalizers/pattern.d.ts +0 -8
  188. package/dist/config/normalizers/pattern.js +0 -41
  189. package/dist/declarations/helpers.d.ts +0 -6
  190. package/dist/declarations/helpers.js +0 -19
  191. package/dist/declarations/index.d.ts +0 -2
  192. package/dist/declarations/index.js +0 -9
  193. package/dist/declarations/types.d.ts +0 -60
  194. package/src/commands/make/template.ts +0 -46
  195. package/src/config/entries.ts +0 -175
  196. package/src/config/normalizers/declaration.ts +0 -179
  197. package/src/config/normalizers/pattern.ts +0 -59
  198. package/src/declarations/helpers.ts +0 -36
  199. package/src/declarations/index.ts +0 -19
  200. package/src/declarations/types.ts +0 -70
  201. /package/dist/{declarations → catalog}/types.js +0 -0
@@ -108,6 +108,8 @@ export const components = pgTable(
108
108
  group: text('group'),
109
109
  type: text('type'),
110
110
  renderer: text('renderer'),
111
+ /** How the implementation file is written (`csf`), when the renderer alone does not say. */
112
+ sourceFormat: text('source_format'),
111
113
  tags: jsonb('tags').$type<string[]>(),
112
114
  categories: jsonb('categories').$type<string[]>(),
113
115
  /** Full normalized component record. */
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Connected-workspace publish orchestration.
3
3
  *
4
- * `publish <components|patterns|pages> <id>` prepares one entity and uploads its record, source files,
5
- * applicable rendered artifacts, and build metadata through the shared registry client.
4
+ * `publish <catalog|pages> <id>` prepares one entity and uploads its record, source files, applicable
5
+ * rendered artifacts, and build metadata through the shared registry client. A catalog item's lane
6
+ * comes from its declaration, so the caller names items and this module resolves the lane.
6
7
  *
7
8
  * The build is targeted: a component builds the global artifacts + that component; a pattern builds
8
9
  * the global artifacts + the components it composes + the pattern itself. This is the smallest build
@@ -18,7 +19,7 @@ import { Logger } from '../../utils/logger';
18
19
  import { createRegistryClient, RegistryClientError, type RegistryClient } from '../client';
19
20
  import { resolveAuthenticatedRegistryConnection } from '../connection';
20
21
  import type { EntitySummary, TransferEntityKind } from '../transfer';
21
- import { selectIds } from '../selection';
22
+ import { quoteIds, selectIds, splitCatalogIds } from '../selection';
22
23
  import { describePublishError, describeUploadFailure } from './errors';
23
24
  import { assertRequiredArtifactsPresent, buildPublishPackage, PublishPackageError } from './package';
24
25
 
@@ -45,6 +46,26 @@ export const assertPublishableWorkspace = (handoff: Handoff): void => {
45
46
  }
46
47
  };
47
48
 
49
+ /** The entity lanes a catalog run visits, in dependency order: a pattern composes components. */
50
+ const CATALOG_LANES = ['component', 'pattern'] as const;
51
+
52
+ const isCatalogLane = (kind: TransferEntityKind): boolean => CATALOG_LANES.some((lane) => lane === kind);
53
+
54
+ /**
55
+ * Refuse a catalog run while the workspace declares one id twice. Discovery keeps the first
56
+ * declaration and skips the rest, so a publish uploads a catalog that silently omits an item.
57
+ */
58
+ const assertUniqueCatalogIds = (handoff: Handoff): void => {
59
+ const duplicates = handoff.runtimeConfig?.duplicateCatalogIds ?? [];
60
+ if (duplicates.length === 0) return;
61
+
62
+ throw new PublishError(
63
+ `Catalog id${duplicates.length > 1 ? 's' : ''} declared twice: ` +
64
+ duplicates.map(({ id, kept, skipped }) => `"${id}" (${kept} and ${skipped})`).join('; ') +
65
+ '. Give each catalog item a unique id.'
66
+ );
67
+ };
68
+
48
69
  /**
49
70
  * Ensure the workspace is a connected workspace able to publish: workspace runtime mode (registry
50
71
  * hosts do not publish) and a resolved registry URL + access token. Throws an actionable
@@ -103,7 +124,7 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
103
124
  // Pages carry no rendered artifacts (raw markdown is rendered at runtime), so there is nothing to
104
125
  // build — the package is assembled directly from the discovered page record + its `.md`.
105
126
  if (kind === 'page') {
106
- if (!handoff.runtimeConfig?.entries?.pages?.[id]) {
127
+ if (!handoff.runtimeConfig?.entities.pages[id]) {
107
128
  throw new PublishPackageError(`Page "${id}" is not declared in this workspace.`);
108
129
  }
109
130
  return;
@@ -115,14 +136,14 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
115
136
  await buildMainCss(handoff);
116
137
 
117
138
  if (kind === 'component') {
118
- if (!handoff.runtimeConfig?.entries?.components?.[id]) {
139
+ if (!handoff.runtimeConfig?.entities.components[id]) {
119
140
  throw new PublishPackageError(`Component "${id}" is not declared in this workspace.`);
120
141
  }
121
142
  await processComponents(handoff, id);
122
143
  return;
123
144
  }
124
145
 
125
- const pattern = handoff.runtimeConfig?.entries?.patterns?.[id];
146
+ const pattern = handoff.runtimeConfig?.entities.patterns[id];
126
147
  if (!pattern) {
127
148
  throw new PublishPackageError(`Pattern "${id}" is not declared in this workspace.`);
128
149
  }
@@ -130,7 +151,7 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
130
151
  // then compose just this pattern.
131
152
  const referencedComponentIds = new Set(pattern.components.map((ref) => ref.id));
132
153
  for (const componentId of Array.from(referencedComponentIds)) {
133
- if (handoff.runtimeConfig?.entries?.components?.[componentId]) {
154
+ if (handoff.runtimeConfig?.entities.components[componentId]) {
134
155
  await processComponents(handoff, componentId);
135
156
  }
136
157
  }
@@ -142,6 +163,7 @@ const runTargetedBuild = async (handoff: Handoff, kind: TransferEntityKind, id:
142
163
  * receive a targeted build, while pages are packaged directly from their source.
143
164
  */
144
165
  export const publishEntity = async (handoff: Handoff, kind: TransferEntityKind, id: string): Promise<void> => {
166
+ if (isCatalogLane(kind)) assertUniqueCatalogIds(handoff);
145
167
  const { client, url } = await resolveTransport(handoff);
146
168
 
147
169
  if (!handoff.skipBuild) {
@@ -218,24 +240,18 @@ const selectEntityIds = (available: string[], requested: string[] | undefined, k
218
240
  };
219
241
 
220
242
  /**
221
- * Publish the components, patterns or pages declared in this connected workspace: every one of the
222
- * kind, or only `ids` when given. Builds the kind once, assembles each entity's package, skips
223
- * entities whose content hash already matches the registry (unless `--force`), and reports
224
- * published/unchanged/failed counts. A per-entity failure is collected and never aborts the rest; the
225
- * run throws at the end if any entity failed.
243
+ * Assemble and upload the selected entities of one kind. The caller runs the build, because a catalog
244
+ * run covers both entity lanes from one build.
245
+ *
246
+ * A per-entity failure is collected and never aborts the rest. Failures are reported here, and the
247
+ * count is returned so the caller raises one error for the whole run.
226
248
  */
227
- export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind, ids?: string[]): Promise<void> => {
228
- const { client, url } = await resolveTransport(handoff);
229
-
230
- if (!handoff.skipBuild) {
231
- Logger.info(`Building ${kind}s for publish…`);
232
- await runBulkBuild(handoff, kind);
233
- }
234
-
249
+ const uploadEntities = async (handoff: Handoff, transport: PublishTransport, kind: TransferEntityKind, ids?: string[]): Promise<number> => {
250
+ const { client, url } = transport;
235
251
  const targets = selectEntityIds(await listWorkspaceEntityIds(handoff, kind), ids, kind);
236
252
  if (targets.length === 0) {
237
253
  Logger.success(`No ${kind}s are declared in this workspace; nothing to publish.`);
238
- return;
254
+ return 0;
239
255
  }
240
256
 
241
257
  // Without a registry there is nothing to compare against, so a dry run reports every target.
@@ -281,10 +297,91 @@ export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind
281
297
  `${kind[0].toUpperCase()}${kind.slice(1)}s publish complete — ${published} ${handoff.dryRun ? 'would be published' : 'published'}, ` +
282
298
  `${unchanged} unchanged${failed.length ? `, ${failed.length} failed` : ''}.`
283
299
  );
284
- if (failed.length > 0) {
285
- for (const failure of failed) {
286
- Logger.error(` - ${failure.id}: ${failure.message}`);
287
- }
288
- throw new PublishError(`${failed.length} ${kind}(s) failed to publish.`);
300
+ for (const failure of failed) {
301
+ Logger.error(` - ${failure.id}: ${failure.message}`);
302
+ }
303
+ return failed.length;
304
+ };
305
+
306
+ /**
307
+ * Publish the components, patterns or pages declared in this connected workspace: every one of the
308
+ * kind, or only `ids` when given. Builds the kind once, assembles each entity's package, skips
309
+ * entities whose content hash already matches the registry (unless `--force`), and reports
310
+ * published/unchanged/failed counts. A per-entity failure is collected and never aborts the rest; the
311
+ * run throws at the end if any entity failed.
312
+ */
313
+ export const publishEntities = async (handoff: Handoff, kind: TransferEntityKind, ids?: string[]): Promise<void> => {
314
+ if (isCatalogLane(kind)) assertUniqueCatalogIds(handoff);
315
+ const transport = await resolveTransport(handoff);
316
+
317
+ if (!handoff.skipBuild) {
318
+ Logger.info(`Building ${kind}s for publish…`);
319
+ await runBulkBuild(handoff, kind);
320
+ }
321
+
322
+ const failed = await uploadEntities(handoff, transport, kind, ids);
323
+ if (failed > 0) {
324
+ throw new PublishError(`${failed} ${kind}(s) failed to publish.`);
325
+ }
326
+ };
327
+
328
+ /**
329
+ * Resolve which lane each requested catalog id belongs to. Without ids every lane runs in full. An
330
+ * unknown id is reported against the whole catalog, because the caller never chose a lane to report
331
+ * it against.
332
+ */
333
+ const resolveCatalogLanes = async (handoff: Handoff, ids?: string[]): Promise<{ kind: TransferEntityKind; ids?: string[] }[]> => {
334
+ if (!ids) {
335
+ return CATALOG_LANES.map((kind) => ({ kind }));
336
+ }
337
+
338
+ const available = {
339
+ component: await listWorkspaceEntityIds(handoff, 'component'),
340
+ pattern: await listWorkspaceEntityIds(handoff, 'pattern'),
341
+ };
342
+ const { lanes, unknown, ambiguous } = splitCatalogIds(ids, available);
343
+
344
+ if (ambiguous.length > 0) {
345
+ throw new PublishError(
346
+ `${quoteIds(ambiguous)} names both a component and a pattern in this workspace. Give each catalog item a unique id.`
347
+ );
348
+ }
349
+ if (unknown.length > 0) {
350
+ const declared = [...available.component, ...available.pattern];
351
+ throw new PublishError(
352
+ `No catalog item named ${quoteIds(unknown)} is declared in this workspace. ` +
353
+ `Declared catalog items: ${declared.join(', ') || '(none)'}.`
354
+ );
355
+ }
356
+
357
+ return CATALOG_LANES.map((kind) => ({ kind, ids: lanes[kind] })).filter((lane) => lane.ids.length > 0);
358
+ };
359
+
360
+ /**
361
+ * Publish the catalog items declared in this connected workspace: every one, or only `ids` when
362
+ * given. An item takes the component lane or the pattern lane depending on whether it declares an
363
+ * implementation or a composition, so the caller names items and never names a lane.
364
+ *
365
+ * One build covers both lanes, because building the patterns already builds every component they
366
+ * compose. A failing lane is reported and never aborts the other. The run throws at the end if any
367
+ * item failed.
368
+ */
369
+ export const publishCatalog = async (handoff: Handoff, ids?: string[]): Promise<void> => {
370
+ assertUniqueCatalogIds(handoff);
371
+ const transport = await resolveTransport(handoff);
372
+ const lanes = await resolveCatalogLanes(handoff, ids);
373
+
374
+ if (!handoff.skipBuild) {
375
+ Logger.info('Building catalog items for publish…');
376
+ // The pattern build runs the component build first, so one call covers both lanes.
377
+ await runBulkBuild(handoff, 'pattern');
378
+ }
379
+
380
+ let failed = 0;
381
+ for (const lane of lanes) {
382
+ failed += await uploadEntities(handoff, transport, lane.kind, lane.ids);
383
+ }
384
+ if (failed > 0) {
385
+ throw new PublishError(`${failed} catalog item(s) failed to publish.`);
289
386
  }
290
387
  };
@@ -33,7 +33,8 @@ export class PublishPackageError extends Error {
33
33
  type RenderedEntityKind = Extract<TransferEntityKind, 'component' | 'pattern'>;
34
34
 
35
35
  /**
36
- * Build a registry-safe `item` record: the built summary enriched with the renderer and the entry
36
+ * Build a registry-safe `item` record: the built summary enriched with the renderer, its source
37
+ * format, and the entry
37
38
  * source-file paths **remapped** from absolute workspace locations to the registry-safe relative
38
39
  * paths the uploaded source files use. Workspace-absolute paths never cross to the registry, and a
39
40
  * later checkout can reconstruct the entity from the relative entries + uploaded files.
@@ -58,6 +59,18 @@ const enrichItem = async (
58
59
  item.renderer = renderer;
59
60
  }
60
61
 
62
+ const sourceFormat = (runtime as { sourceFormat?: string }).sourceFormat;
63
+ if (sourceFormat) {
64
+ item.sourceFormat = sourceFormat;
65
+ }
66
+
67
+ const componentExport = (runtime as ComponentListObject).componentExport;
68
+ if (componentExport) item.componentExport = componentExport;
69
+ if (kind === 'component') {
70
+ // PostgreSQL jsonb does not preserve object key order.
71
+ item.previewOrder = Object.keys((runtime as ComponentListObject).previews ?? {});
72
+ }
73
+
61
74
  if (rawEntries && sourceDir) {
62
75
  const remapped: Record<string, string> = {};
63
76
  for (const [key, value] of Object.entries(rawEntries)) {
@@ -4,6 +4,9 @@
4
4
  * Every bulk operation discovers what is available (declared locally or published remotely) and may
5
5
  * be asked for only part of it. Splitting the request into what exists and what does not lets each
6
6
  * caller build its own actionable error while the matching lives in one place.
7
+ *
8
+ * A catalog request needs one more step. A catalog item normalizes into a component or a pattern
9
+ * from its declaration, so the CLI names the item and the lane is looked up here.
7
10
  */
8
11
 
9
12
  /** Narrow an available list to the requested subset, separating out ids that do not exist. */
@@ -17,3 +20,44 @@ export const selectIds = (available: string[], requested?: string[]): { selected
17
20
  unknown: requested.filter((id) => !availableIds.has(id)),
18
21
  };
19
22
  };
23
+
24
+ /** Catalog item ids grouped by lane. */
25
+ export interface CatalogLanes {
26
+ component: string[];
27
+ pattern: string[];
28
+ }
29
+
30
+ /**
31
+ * Route each requested catalog id to its lane. The two lanes are keyed separately, so nothing stops a
32
+ * workspace from declaring one id twice. Such an id is reported rather than guessed, because picking
33
+ * a lane silently would act on the wrong entity.
34
+ */
35
+ export const splitCatalogIds = (
36
+ requested: string[],
37
+ available: CatalogLanes
38
+ ): { lanes: CatalogLanes; unknown: string[]; ambiguous: string[] } => {
39
+ const components = new Set(available.component);
40
+ const patterns = new Set(available.pattern);
41
+ const lanes: CatalogLanes = { component: [], pattern: [] };
42
+ const unknown: string[] = [];
43
+ const ambiguous: string[] = [];
44
+
45
+ for (const id of requested) {
46
+ const isComponent = components.has(id);
47
+ const isPattern = patterns.has(id);
48
+ if (isComponent && isPattern) {
49
+ ambiguous.push(id);
50
+ } else if (isComponent) {
51
+ lanes.component.push(id);
52
+ } else if (isPattern) {
53
+ lanes.pattern.push(id);
54
+ } else {
55
+ unknown.push(id);
56
+ }
57
+ }
58
+
59
+ return { lanes, unknown, ambiguous };
60
+ };
61
+
62
+ /** Quote ids for an error message: `"button", "card"`. */
63
+ export const quoteIds = (ids: string[]): string => ids.map((id) => `"${id}"`).join(', ');
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Filesystem-backed implementation of the normalized store abstraction.
3
3
  *
4
- * It is a thin read view over the already-resolved `runtimeConfig.entries`, so the records it
4
+ * It is a thin read view over the already-resolved `runtimeConfig.entities`, so the records it
5
5
  * returns are the same normalized component/pattern records the build consumes today — discovery
6
6
  * and normalization (modern `*.handoff.*` + legacy `{dirname}.*`, react/handlebars/csf/plain-object
7
7
  * declarations, CSF story discovery, `entries` resolution) all happen upstream in
@@ -71,7 +71,7 @@ export class FilesystemComponentStore implements ComponentStore {
71
71
  constructor(private readonly context: FilesystemStoreContext) {}
72
72
 
73
73
  private get records(): Record<string, ComponentListObject> {
74
- return this.context.runtimeConfig?.entries?.components ?? {};
74
+ return this.context.runtimeConfig?.entities.components ?? {};
75
75
  }
76
76
 
77
77
  list(): ComponentListObject[] {
@@ -97,7 +97,7 @@ export class FilesystemPatternStore implements PatternStore {
97
97
  constructor(private readonly context: FilesystemStoreContext) {}
98
98
 
99
99
  private get records(): Record<string, PatternListObject> {
100
- return this.context.runtimeConfig?.entries?.patterns ?? {};
100
+ return this.context.runtimeConfig?.entities.patterns ?? {};
101
101
  }
102
102
 
103
103
  list(): PatternListObject[] {
@@ -145,7 +145,7 @@ export class FilesystemPageStore implements PageStore {
145
145
  constructor(private readonly context: FilesystemStoreContext) {}
146
146
 
147
147
  private get records(): Record<string, PageListObject> {
148
- return this.context.runtimeConfig?.entries?.pages ?? {};
148
+ return this.context.runtimeConfig?.entities.pages ?? {};
149
149
  }
150
150
 
151
151
  list(): PageListObject[] {
@@ -14,6 +14,7 @@
14
14
  * returned exactly as it was published.
15
15
  */
16
16
 
17
+ import { orderPreviews } from '../catalog/previews';
17
18
  import { and, eq, inArray, or, sql, type SQLWrapper } from 'drizzle-orm';
18
19
  import type { AssetStorage, AssetStorageReadResult } from '../registry/asset-storage/types';
19
20
  import type { RegistryDatabase } from '../registry/db/client';
@@ -86,17 +87,27 @@ const toTextFileResource = (row: RegistryFileRow): TextFileResource | null => {
86
87
  };
87
88
  };
88
89
 
90
+ /**
91
+ * Restore source order after jsonb storage without exposing transfer-only metadata. `entries` and
92
+ * `componentExport` describe the publisher's source layout and exist for checkout. The workspace
93
+ * has no equivalent, so the docs API must not serve them.
94
+ */
95
+ const readComponentRecord = (record: ComponentListObject): ComponentListObject => {
96
+ const { previewOrder, entries, componentExport, ...item } = record;
97
+ return { ...item, previews: orderPreviews(item.previews ?? {}, previewOrder ?? []) };
98
+ };
99
+
89
100
  export class RegistryComponentStore implements ComponentStore {
90
101
  constructor(private readonly context: RegistryStoreContext) {}
91
102
 
92
103
  async list(): Promise<ComponentListObject[]> {
93
104
  const rows = await this.context.db.select({ record: components.record }).from(components);
94
- return rows.map((row) => row.record);
105
+ return rows.map((row) => readComponentRecord(row.record));
95
106
  }
96
107
 
97
108
  async get(id: string): Promise<ComponentListObject | null> {
98
109
  const rows = await this.context.db.select({ record: components.record }).from(components).where(eq(components.id, id)).limit(1);
99
- return rows[0]?.record ?? null;
110
+ return rows[0] ? readComponentRecord(rows[0].record) : null;
100
111
  }
101
112
 
102
113
  /**
@@ -7,6 +7,7 @@ import ReactDOMServer from 'react-dom/server';
7
7
  import reactElementToJSXString from 'react-element-to-jsx-string';
8
8
  import { Plugin } from 'vite';
9
9
  import Handoff from '../..';
10
+ import { orderPreviews, readExportOrderFromSource } from '../../catalog';
10
11
  import { Logger } from '../../utils/logger';
11
12
  import { generateDocsArtifact, getPropertiesForComponentFromDocs } from '../docgen';
12
13
  import { SlotMetadata, SlotType } from '../preview/component';
@@ -358,9 +359,10 @@ export function csfRenderPlugin(
358
359
  Object.entries(incomingPreviews).filter(([key]) => key.startsWith('__pattern_'))
359
360
  );
360
361
 
361
- // Include all named stories as previews and preserve injected pattern previews.
362
+ // Include all named stories as previews and preserve injected pattern previews. Story order
363
+ // comes from the source text: esbuild emits its export map alphabetically.
362
364
  componentData.previews = {
363
- ...createCsfStoryPreviews(moduleExports),
365
+ ...orderPreviews(createCsfStoryPreviews(moduleExports), readExportOrderFromSource(sourceCode)),
364
366
  ...patternPreviews,
365
367
  };
366
368
 
@@ -60,6 +60,13 @@ const CLIENT_ARTIFACT_SUFFIX = 'client.js';
60
60
  /** Logical artifact path of a component's client/hydration bundle. */
61
61
  const clientArtifactPath = (componentId: string): string => `component/${componentId}.${CLIENT_ARTIFACT_SUFFIX}`;
62
62
 
63
+ function selectComponentExport(moduleExports: Record<string, any>, exportName?: string): ReactComponent | null {
64
+ if (exportName && exportName !== 'default' && moduleExports?.[exportName]) {
65
+ return moduleExports[exportName];
66
+ }
67
+ return moduleExports?.default ?? null;
68
+ }
69
+
63
70
  /**
64
71
  * Loads and processes component schema using hierarchical approach
65
72
  * @param componentData - Component transformation data
@@ -85,7 +92,7 @@ async function loadComponentSchemaAndModule(
85
92
  if (!componentData.entries?.schema) {
86
93
  try {
87
94
  const moduleExports = await buildAndEvaluateModule(componentPath, handoff);
88
- component = moduleExports.exports.default;
95
+ component = selectComponentExport(moduleExports.exports, componentData.componentExport);
89
96
 
90
97
  // Try to load schema from component exports
91
98
  properties = await loadSchemaFromComponent(moduleExports.exports, handoff);
@@ -103,7 +110,7 @@ async function loadComponentSchemaAndModule(
103
110
  if (!component) {
104
111
  try {
105
112
  const moduleExports = await buildAndEvaluateModule(componentPath, handoff);
106
- component = moduleExports.exports.default;
113
+ component = selectComponentExport(moduleExports.exports, componentData.componentExport);
107
114
  } catch (error) {
108
115
  Logger.error(`Failed to load component for rendering "${componentPath}":`, error);
109
116
  return [null, null];
@@ -128,13 +135,16 @@ async function loadComponentSchemaAndModule(
128
135
  *
129
136
  * @param componentId - Component identifier used to match pattern mount points to this bundle
130
137
  * @param componentPath - Path to the component file
138
+ * @param componentExport - Export holding the implementation; the default export when absent
131
139
  * @returns Client-side hydration source code
132
140
  */
133
- function generateClientHydrationSource(componentId: string, componentPath: string): string {
141
+ function generateClientHydrationSource(componentId: string, componentPath: string, componentExport?: string): string {
142
+ const importClause = componentExport && componentExport !== 'default' ? `{ ${componentExport} as Component }` : 'Component';
143
+
134
144
  return `
135
145
  import React from 'react';
136
146
  import { hydrateRoot } from 'react-dom/client';
137
- import Component from '${normalizePath(componentPath)}';
147
+ import ${importClause} from '${normalizePath(componentPath)}';
138
148
 
139
149
  const parseProps = (propsId) => {
140
150
  const raw = propsId ? document.getElementById(propsId)?.textContent : '{}';
@@ -304,7 +314,7 @@ export function ssrRenderPlugin(
304
314
  // `component/<id>.client.js` artifact. The hydration source only imports the component and
305
315
  // reads props from the in-document `__APP_PROPS__` element, so it is identical across every
306
316
  // preview of this component.
307
- const clientHydrationSource = generateClientHydrationSource(componentId, componentPath);
317
+ const clientHydrationSource = generateClientHydrationSource(componentId, componentPath, componentData.componentExport);
308
318
  const clientBuildConfig = {
309
319
  ...DEFAULT_CLIENT_BUILD_CONFIG,
310
320
  logLevel: 'silent' as const,
@@ -5,6 +5,14 @@ import Handoff from '../../../index';
5
5
  import { ComponentListObject, TransformComponentTokensResult } from '../types';
6
6
  import { getDocumentedPreviews } from './previews';
7
7
 
8
+ /**
9
+ * Strip the fields that must not reach the written artifact.
10
+ *
11
+ * The docs API serves this artifact publicly, and publish uploads it verbatim. An absolute path in
12
+ * the artifact exposes the filesystem layout of the build machine, so this drops the workspace
13
+ * locations `path` and `entries`. The build takes those locations from the runtime config, and
14
+ * publish remaps `entries` onto the registry record.
15
+ */
8
16
  export const sanitizeComponentApiData = (
9
17
  component: TransformComponentTokensResult
10
18
  ): TransformComponentTokensResult => {
@@ -13,9 +21,11 @@ export const sanitizeComponentApiData = (
13
21
  const sanitized = {
14
22
  ...component,
15
23
  previews: getDocumentedPreviews(component.previews),
16
- };
24
+ } as NonNullable<TransformComponentTokensResult> & { path?: string };
17
25
 
18
26
  delete sanitized.internalPatternPreviews;
27
+ delete sanitized.entries;
28
+ delete sanitized.path;
19
29
 
20
30
  return sanitized;
21
31
  };
@@ -150,6 +160,8 @@ export const readComponentMetadataApi = async (handoff: Handoff, id: string): Pr
150
160
  properties: componentData.properties,
151
161
  previews: getDocumentedPreviews(componentData.previews),
152
162
  path: buildComponentDetailUrl(id, process.env.HANDOFF_APP_BASE_PATH ?? ''),
163
+ ...(componentData.renderer ? { renderer: componentData.renderer } : {}),
164
+ ...(componentData.sourceFormat ? { sourceFormat: componentData.sourceFormat } : {}),
153
165
  };
154
166
  };
155
167
  export default writeComponentApi;
@@ -16,7 +16,7 @@ const CLIENT_ARTIFACT_SUFFIX = '.client.js';
16
16
  export const getComponentApiPath = (handoff: Handoff) => path.resolve(getAPIPath(handoff), 'component');
17
17
 
18
18
  const getComponentPreviewKeys = async (handoff: Handoff, componentId: string): Promise<Set<string>> => {
19
- const runtimeComponent = handoff.runtimeConfig?.entries?.components?.[componentId];
19
+ const runtimeComponent = handoff.runtimeConfig?.entities.components[componentId];
20
20
  const previewKeys = new Set<string>();
21
21
 
22
22
  const outputFilePath = path.resolve(getComponentApiPath(handoff), `${componentId}.json`);
@@ -67,15 +67,19 @@ export const removeComponentApi = async (handoff: Handoff, id: string): Promise<
67
67
  }
68
68
  };
69
69
 
70
- export const syncComponentArtifacts = async (handoff: Handoff): Promise<void> => {
70
+ export const syncComponentArtifacts = async (handoff: Handoff, removedIds: readonly string[] = []): Promise<void> => {
71
71
  const componentPath = getComponentApiPath(handoff);
72
72
  await fs.ensureDir(componentPath);
73
73
 
74
- const runtimeComponents = handoff.runtimeConfig?.entries?.components ?? {};
74
+ const runtimeComponents = handoff.runtimeConfig?.entities.components ?? {};
75
75
  const runtimeIds = Object.keys(runtimeComponents);
76
76
  const entries = await fs.readdir(componentPath);
77
77
 
78
- const discoveredArtifactIds = new Set<string>(runtimeIds);
78
+ // `getArtifactComponentId` prunes a preview file only when it can name the owner. A deleted
79
+ // component is gone from the runtime config, and its `.json` is already removed, so nothing can
80
+ // match it. Naming it here restores the match without making its files valid. Every artifact it
81
+ // owns is then pruned, including the `__pattern_*` previews that the `.json` does not list.
82
+ const discoveredArtifactIds = new Set<string>([...runtimeIds, ...removedIds]);
79
83
  for (const entry of entries) {
80
84
  const parsed = path.parse(entry);
81
85
  if (parsed.ext === '.json' || parsed.ext === '.js' || parsed.ext === '.css') {
@@ -1,6 +1,7 @@
1
1
  import { Types as CoreTypes } from 'handoff-core';
2
2
  import cloneDeep from 'lodash/cloneDeep';
3
3
  import { buildComponentDetailUrl } from '../../../artifacts/url';
4
+ import type { SourceFormat } from '../../../catalog/renderers';
4
5
  import {
5
6
  BuildCache,
6
7
  checkOutputExists,
@@ -67,8 +68,16 @@ type ComponentBuildPlan = {
67
68
  validationMode: boolean;
68
69
  };
69
70
 
71
+ /**
72
+ * A source format reads its previews out of the source file. A placeholder there is a preview
73
+ * nothing can render: the docs would offer a "Default" entry with no artifact. Only a renderer
74
+ * that renders whatever previews it is given needs the placeholder.
75
+ */
76
+ const ownsItsPreviews = (data: { sourceFormat?: SourceFormat } | undefined): boolean => !!data?.sourceFormat;
77
+
70
78
  const ensureDefaultPreview = (data: TransformComponentTokensResult): void => {
71
79
  if (!data) return;
80
+ if (ownsItsPreviews(data)) return;
72
81
  if (!data.previews || Object.keys(data.previews).length === 0) {
73
82
  data.previews = {
74
83
  default: {
@@ -180,7 +189,7 @@ export async function processComponents(
180
189
  const documentationObject = await handoff.getDocumentationObject();
181
190
  const components = documentationObject?.components ?? ({} as CoreTypes.IDocumentationObject['components']);
182
191
  // 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
192
+ // read view over `runtimeConfig.entities.components`, so these are the same records the build has
184
193
  // always used — rebuilt into the id-keyed map the build logic below expects.
185
194
  const runtimeComponents: Record<string, ComponentListObject> = {};
186
195
  for (const component of await handoff.store.components.list()) {
@@ -191,7 +200,7 @@ export async function processComponents(
191
200
  if (id && !runtimeComponents[id]) {
192
201
  await removeComponentApi(handoff, id);
193
202
  await removeComponentFromSummaryApi(handoff, id);
194
- await syncComponentArtifacts(handoff);
203
+ await syncComponentArtifacts(handoff, [id]);
195
204
  return [];
196
205
  }
197
206
 
@@ -302,7 +311,7 @@ export async function processComponents(
302
311
  // If this is NOT a figma component, add the default generic preview.
303
312
  // We add it here (before merge) so that if the user explicitly provided previews in 'restMetadata',
304
313
  // those will override this default (standard "config overrides defaults" behavior).
305
- if (!restMetadata.figmaComponentId) {
314
+ if (!restMetadata.figmaComponentId && !ownsItsPreviews(restMetadata)) {
306
315
  componentDefaults.previews = {
307
316
  default: {
308
317
  title: 'Default',
@@ -337,12 +346,14 @@ export async function processComponents(
337
346
  data.css = existingData.css;
338
347
  data.sass = existingData.sass;
339
348
  }
340
- // If we're not building previews, preserve pre-existing HTML, code snippet, usage, and previews.
349
+ // If we're not building previews, preserve pre-existing HTML, code snippet, usage, previews,
350
+ // and the code language they were rendered in.
341
351
  if (!buildPlan.previews) {
342
352
  data.html = existingData.html;
343
353
  data.code = existingData.code;
344
354
  data.usage = existingData.usage;
345
355
  data.previews = existingData.previews;
356
+ data.format = existingData.format;
346
357
  }
347
358
  /**
348
359
  * Always keep validation results from the previous data,
@@ -466,6 +477,8 @@ const buildComponentSummary = (id: string, data: TransformComponentTokensResult)
466
477
  properties: data.properties,
467
478
  previews: getDocumentedPreviews(data.previews),
468
479
  path: buildComponentDetailUrl(id, process.env.HANDOFF_APP_BASE_PATH ?? ''),
480
+ ...(data.renderer ? { renderer: data.renderer } : {}),
481
+ ...(data.sourceFormat ? { sourceFormat: data.sourceFormat } : {}),
469
482
  };
470
483
  };
471
484
 
@@ -1,7 +1,6 @@
1
1
  import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import { InlineConfig, build as viteBuild } from 'vite';
4
- import { initRuntimeConfig } from '../../../config';
5
4
  import Handoff from '../../../index';
6
5
  import { formatDurationMs } from '../../../utils/duration';
7
6
  import { Logger } from '../../../utils/logger';
@@ -184,13 +183,13 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
184
183
  export const buildMainCss = async (handoff: Handoff): Promise<void> => {
185
184
  const outputPath = getComponentOutputPath(handoff);
186
185
  const mainCssPath = path.resolve(outputPath, MAIN_COMPONENT_CSS_FILE);
187
- const runtimeConfig = initRuntimeConfig(handoff)[0];
186
+ const scssEntry = handoff.runtimeConfig?.entries?.scss;
188
187
 
189
- if (runtimeConfig?.entries?.scss && fs.existsSync(runtimeConfig.entries.scss)) {
190
- const stat = await fs.stat(runtimeConfig.entries.scss);
191
- const entryPath = stat.isDirectory() ? path.resolve(runtimeConfig.entries.scss, 'main.scss') : runtimeConfig.entries.scss;
188
+ if (scssEntry && fs.existsSync(scssEntry)) {
189
+ const stat = await fs.stat(scssEntry);
190
+ const entryPath = stat.isDirectory() ? path.resolve(scssEntry, 'main.scss') : scssEntry;
192
191
 
193
- if (entryPath === runtimeConfig.entries.scss || fs.existsSync(entryPath)) {
192
+ if (entryPath === scssEntry || fs.existsSync(entryPath)) {
194
193
  Logger.info(`Building styles for global entry (${MAIN_COMPONENT_CSS_FILE})…`);
195
194
  const startedAt = Date.now();
196
195
  // Drop the previous artifact so a failed/empty rebuild cannot preserve stale global CSS that
@@ -198,15 +197,12 @@ export const buildMainCss = async (handoff: Handoff): Promise<void> => {
198
197
  await fs.remove(mainCssPath);
199
198
  try {
200
199
  const loadPaths = [
200
+ path.dirname(scssEntry),
201
201
  path.resolve(handoff.workingPath),
202
202
  path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
203
203
  path.resolve(handoff.workingPath, 'node_modules'),
204
204
  ];
205
205
 
206
- if (handoff.runtimeConfig?.entries?.scss) {
207
- loadPaths.unshift(path.dirname(handoff.runtimeConfig.entries.scss));
208
- }
209
-
210
206
  await buildCssBundle({
211
207
  entry: entryPath,
212
208
  outputPath,