handoff-app 1.1.3 → 1.2.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 (143) hide show
  1. package/config/config.template.js +21 -14
  2. package/config/config.template.ts +54 -0
  3. package/config/docs/system.md +2 -0
  4. package/dist/app-builder/build.js +7 -2
  5. package/dist/app-builder/config-diff/index.d.ts +4 -0
  6. package/dist/app-builder/config-diff/index.js +12 -0
  7. package/dist/app-builder/config-diff/registry.d.ts +9 -0
  8. package/dist/app-builder/config-diff/registry.js +30 -0
  9. package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
  10. package/dist/app-builder/config-diff/snapshot.js +43 -0
  11. package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
  12. package/dist/app-builder/config-diff/strategies/pattern.js +93 -0
  13. package/dist/app-builder/config-diff/types.d.ts +19 -0
  14. package/dist/app-builder/config-diff/types.js +2 -0
  15. package/dist/app-builder/watchers/component.d.ts +15 -0
  16. package/dist/app-builder/watchers/component.js +69 -0
  17. package/dist/app-builder/watchers/utils.d.ts +14 -0
  18. package/dist/app-builder/watchers/utils.js +44 -0
  19. package/dist/app-builder/watchers.d.ts +10 -19
  20. package/dist/app-builder/watchers.js +56 -102
  21. package/dist/cache/build-cache.js +14 -5
  22. package/dist/cli/create.js +67 -23
  23. package/dist/cli/eject.js +48 -7
  24. package/dist/cli/make.js +29 -4
  25. package/dist/cli/scaffold.js +60 -19
  26. package/dist/cli.js +1 -1
  27. package/dist/config/helpers.d.ts +7 -0
  28. package/dist/config/helpers.js +31 -0
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +5 -1
  31. package/dist/config/loader.d.ts +10 -1
  32. package/dist/config/loader.js +92 -28
  33. package/dist/config/normalizers/declaration.d.ts +11 -0
  34. package/dist/config/normalizers/declaration.js +119 -0
  35. package/dist/config/normalizers/pattern.d.ts +9 -0
  36. package/dist/config/normalizers/pattern.js +38 -0
  37. package/dist/config/runtime.d.ts +9 -3
  38. package/dist/config/runtime.js +254 -33
  39. package/dist/declarations/helpers.d.ts +6 -0
  40. package/dist/declarations/helpers.js +19 -0
  41. package/dist/declarations/index.d.ts +2 -0
  42. package/dist/declarations/index.js +9 -0
  43. package/dist/declarations/types.d.ts +60 -0
  44. package/dist/declarations/types.js +2 -0
  45. package/dist/index.d.ts +12 -1
  46. package/dist/index.js +34 -5
  47. package/dist/pipeline/index.d.ts +1 -0
  48. package/dist/pipeline/index.js +3 -1
  49. package/dist/pipeline/patterns.d.ts +6 -0
  50. package/dist/pipeline/patterns.js +21 -0
  51. package/dist/transformers/docgen/index.js +12 -2
  52. package/dist/transformers/plugins/csf-render.js +170 -34
  53. package/dist/transformers/plugins/ssr-render.js +18 -2
  54. package/dist/transformers/preview/component/api.js +16 -7
  55. package/dist/transformers/preview/component/builder.js +9 -3
  56. package/dist/transformers/preview/component/css.d.ts +8 -0
  57. package/dist/transformers/preview/component/html.js +14 -5
  58. package/dist/transformers/preview/component/previews.d.ts +9 -0
  59. package/dist/transformers/preview/component/previews.js +10 -0
  60. package/dist/transformers/preview/pattern/api.d.ts +7 -0
  61. package/dist/transformers/preview/pattern/api.js +61 -0
  62. package/dist/transformers/preview/pattern/builder.d.ts +9 -0
  63. package/dist/transformers/preview/pattern/builder.js +86 -0
  64. package/dist/transformers/preview/pattern/html.d.ts +19 -0
  65. package/dist/transformers/preview/pattern/html.js +94 -0
  66. package/dist/transformers/preview/types.d.ts +54 -0
  67. package/dist/transformers/utils/csf.d.ts +16 -0
  68. package/dist/transformers/utils/csf.js +30 -0
  69. package/dist/transformers/utils/index.d.ts +1 -0
  70. package/dist/transformers/utils/index.js +2 -0
  71. package/dist/transformers/utils/module.d.ts +5 -0
  72. package/dist/transformers/utils/module.js +26 -13
  73. package/dist/transformers/utils/usage.d.ts +23 -0
  74. package/dist/transformers/utils/usage.js +294 -0
  75. package/dist/types/config.d.ts +32 -7
  76. package/dist/types/preview.d.ts +2 -0
  77. package/docs/cli.md +3 -3
  78. package/package.json +3 -1
  79. package/src/app/components/Component/PageSliceResolver.tsx +6 -6
  80. package/src/app/components/Component/Preview.tsx +17 -12
  81. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
  82. package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
  83. package/src/app/components/util/index.ts +71 -2
  84. package/src/app/pages/system/pattern/[pattern]/index.tsx +223 -0
  85. package/src/app/pages/system/pattern/index.tsx +14 -0
  86. package/src/app-builder/README.md +37 -9
  87. package/src/app-builder/build.ts +8 -2
  88. package/src/app-builder/config-diff/index.ts +4 -0
  89. package/src/app-builder/config-diff/registry.ts +23 -0
  90. package/src/app-builder/config-diff/snapshot.ts +51 -0
  91. package/src/app-builder/config-diff/strategies/pattern.ts +65 -0
  92. package/src/app-builder/config-diff/types.ts +21 -0
  93. package/src/app-builder/watchers/component.ts +72 -0
  94. package/src/app-builder/watchers/utils.ts +44 -0
  95. package/src/app-builder/watchers.ts +62 -80
  96. package/src/cache/build-cache.ts +14 -5
  97. package/src/cli/create.ts +86 -25
  98. package/src/cli/eject.ts +29 -7
  99. package/src/cli/make.ts +32 -5
  100. package/src/cli/scaffold.ts +68 -21
  101. package/src/cli.ts +1 -1
  102. package/src/config/README.md +10 -1
  103. package/src/config/helpers.ts +43 -0
  104. package/src/config/index.ts +4 -1
  105. package/src/config/loader.ts +110 -31
  106. package/src/config/normalizers/declaration.ts +176 -0
  107. package/src/config/normalizers/pattern.ts +63 -0
  108. package/src/config/runtime.ts +310 -35
  109. package/src/declarations/helpers.ts +36 -0
  110. package/src/declarations/index.ts +19 -0
  111. package/src/declarations/types.ts +70 -0
  112. package/src/index.ts +47 -6
  113. package/src/pipeline/index.ts +1 -0
  114. package/src/pipeline/patterns.ts +10 -0
  115. package/src/transformers/README.md +2 -2
  116. package/src/transformers/docgen/index.ts +13 -2
  117. package/src/transformers/plugins/csf-render.ts +216 -49
  118. package/src/transformers/plugins/ssr-render.ts +23 -7
  119. package/src/transformers/preview/component/api.ts +27 -7
  120. package/src/transformers/preview/component/builder.ts +21 -4
  121. package/src/transformers/preview/component/html.ts +18 -5
  122. package/src/transformers/preview/component/previews.ts +12 -0
  123. package/src/transformers/preview/pattern/api.ts +66 -0
  124. package/src/transformers/preview/pattern/builder.ts +94 -0
  125. package/src/transformers/preview/pattern/html.ts +107 -0
  126. package/src/transformers/preview/types.ts +58 -1
  127. package/src/transformers/utils/csf.ts +48 -0
  128. package/src/transformers/utils/index.ts +3 -0
  129. package/src/transformers/utils/module.ts +33 -18
  130. package/src/transformers/utils/usage.ts +319 -0
  131. package/src/types/README.md +1 -1
  132. package/src/types/config.ts +33 -7
  133. package/src/types/preview.ts +2 -0
  134. package/templates/default/components/button/Button.jsx.tpl +64 -0
  135. package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
  136. package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
  137. package/templates/default/handoff.config.js.tpl +29 -16
  138. package/templates/default/handoff.config.ts.tpl +196 -0
  139. package/templates/default/handoff.config.with-examples.js.tpl +28 -17
  140. package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
  141. package/templates/default/package.json.tpl +1 -3
  142. package/handoff.config.js +0 -90
  143. package/templates/default/components/button/button.js.tpl +0 -62
@@ -0,0 +1,19 @@
1
+ export {
2
+ defineComponent,
3
+ defineCsfComponent,
4
+ defineHandlebarsComponent,
5
+ definePattern,
6
+ defineReactComponent
7
+ } from './helpers';
8
+
9
+ export type {
10
+ CsfDeclarationConfig,
11
+ DeclarationPreview,
12
+ GenericDeclarationConfig,
13
+ GenericPatternDeclarationConfig,
14
+ HandlebarsDeclarationConfig,
15
+ PatternComponentRef,
16
+ ReactDeclarationConfig,
17
+ RendererKind
18
+ } from './types';
19
+
@@ -0,0 +1,70 @@
1
+ import type React from 'react';
2
+ import type { ComponentObject } from '../transformers/preview/types';
3
+
4
+ export type RendererKind = 'react' | 'handlebars' | 'csf';
5
+
6
+ export type DeclarationPreview<TArgs = Record<string, any>> = {
7
+ title: string;
8
+ args?: TArgs;
9
+ values?: TArgs;
10
+ url?: string;
11
+ usage?: string;
12
+ };
13
+
14
+ type BaseDeclarationEntries = NonNullable<ComponentObject['entries']> & {
15
+ component?: string;
16
+ story?: string;
17
+ templates?: string;
18
+ };
19
+
20
+ type OptionalComponentMetadata = Partial<Omit<ComponentObject, 'previews' | 'entries' | 'title' | 'should_do' | 'should_not_do'>>;
21
+
22
+ export type BaseDeclarationConfig = OptionalComponentMetadata & {
23
+ id?: string;
24
+ name: string;
25
+ renderer?: RendererKind;
26
+ entries?: BaseDeclarationEntries;
27
+ previews?: Record<string, DeclarationPreview>;
28
+ shouldDo?: string[];
29
+ shouldNotDo?: string[];
30
+ };
31
+
32
+ export type ReactDeclarationConfig<TProps> = Omit<BaseDeclarationConfig, 'renderer' | 'entries' | 'previews'> & {
33
+ entries: BaseDeclarationEntries & { component: string };
34
+ previews: Record<string, DeclarationPreview<Partial<TProps>>>;
35
+ };
36
+
37
+ export type HandlebarsDeclarationConfig = Omit<BaseDeclarationConfig, 'renderer' | 'entries'> & {
38
+ entries: BaseDeclarationEntries & { template: string };
39
+ };
40
+
41
+ export type CsfDeclarationConfig = Omit<BaseDeclarationConfig, 'renderer' | 'entries'> & {
42
+ entries: BaseDeclarationEntries & { story: string };
43
+ };
44
+
45
+ export type GenericDeclarationConfig = Omit<BaseDeclarationConfig, 'renderer'> & {
46
+ renderer: RendererKind;
47
+ };
48
+
49
+ export type ReactComponentType<TProps = any> = React.ComponentType<TProps>;
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Pattern declarations
53
+ // ---------------------------------------------------------------------------
54
+
55
+ export type PatternComponentRef = {
56
+ id: string;
57
+ preview?: string;
58
+ args?: Record<string, any>;
59
+ };
60
+
61
+ export type BasePatternDeclarationConfig = {
62
+ id?: string;
63
+ name: string;
64
+ description?: string;
65
+ group?: string;
66
+ tags?: string[];
67
+ components: PatternComponentRef[];
68
+ };
69
+
70
+ export type GenericPatternDeclarationConfig = BasePatternDeclarationConfig;
package/src/index.ts CHANGED
@@ -5,11 +5,12 @@ import path from 'path';
5
5
  import buildApp, { devApp, watchApp } from './app-builder';
6
6
  import { ejectConfig, ejectPages, ejectTheme } from './cli/eject';
7
7
  import { makeComponent, makePage, makeTemplate } from './cli/make';
8
- import { initConfig, initRuntimeConfig, validateConfig } from './config';
9
- import pipeline, { buildComponents } from './pipeline';
8
+ import { initConfigWithMetadata, initRuntimeConfig, validateConfig } from './config';
9
+ import pipeline, { buildComponents, buildPatterns } from './pipeline';
10
10
  import processComponents, { ComponentSegment } from './transformers/preview/component/builder';
11
- import { Config, RuntimeConfig } from './types/config';
11
+ import { Config, ConfigFileEntry, RuntimeConfig } from './types/config';
12
12
  import { Logger } from './utils/logger';
13
+ import { normalizePathForCompare } from './utils/path';
13
14
  import { generateFilesystemSafeId } from './utils/path';
14
15
 
15
16
  class Handoff {
@@ -29,6 +30,8 @@ class Handoff {
29
30
 
30
31
  private _initialArgs: { debug?: boolean; force?: boolean; config?: Partial<Config> } = {};
31
32
  private _configFilePaths: string[] = [];
33
+ private _configFileIndex: Map<string, ConfigFileEntry> = new Map();
34
+ private _mainConfigFilePath?: string;
32
35
  private _documentationObjectCache?: CoreTypes.IDocumentationObject;
33
36
  private _handoffRunner?: ReturnType<typeof HandoffRunner> | null;
34
37
 
@@ -47,11 +50,13 @@ class Handoff {
47
50
  }
48
51
 
49
52
  init(configOverride?: Partial<Config>): Handoff {
50
- const config = initConfig(configOverride ?? {});
53
+ const configResult = initConfigWithMetadata(configOverride ?? {});
54
+ const config = configResult.config;
51
55
  this.config = config;
56
+ this._mainConfigFilePath = configResult.configPath;
52
57
  this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
53
58
  this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
54
- [this.runtimeConfig, this._configFilePaths] = initRuntimeConfig(this);
59
+ [this.runtimeConfig, this._configFilePaths, this._configFileIndex] = initRuntimeConfig(this);
55
60
  if(this.config.app.base_path && !process.env.HANDOFF_APP_BASE_PATH) {
56
61
  process.env.HANDOFF_APP_BASE_PATH = this.config.app.base_path ?? '';
57
62
  }
@@ -92,6 +97,12 @@ class Handoff {
92
97
  return this;
93
98
  }
94
99
 
100
+ async pattern(): Promise<Handoff> {
101
+ this.preRunner();
102
+ await buildPatterns(this);
103
+ return this;
104
+ }
105
+
95
106
  async build(skipComponents?: boolean): Promise<Handoff> {
96
107
  this.preRunner();
97
108
  await buildApp(this, skipComponents);
@@ -271,7 +282,19 @@ class Handoff {
271
282
  * @returns {string[]} Array of absolute paths to config files
272
283
  */
273
284
  getConfigFilePaths(): string[] {
274
- return this._configFilePaths;
285
+ const combined = this._mainConfigFilePath ? [this._mainConfigFilePath, ...this._configFilePaths] : this._configFilePaths;
286
+ return Array.from(new Set(combined));
287
+ }
288
+
289
+ /**
290
+ * Gets the selected main config file path if one exists.
291
+ */
292
+ getMainConfigFilePath(): string | undefined {
293
+ return this._mainConfigFilePath;
294
+ }
295
+
296
+ getConfigFileEntry(configPath: string): ConfigFileEntry | undefined {
297
+ return this._configFileIndex.get(normalizePathForCompare(configPath));
275
298
  }
276
299
 
277
300
  /**
@@ -298,6 +321,24 @@ class Handoff {
298
321
 
299
322
  export type { ComponentObject as Component } from './transformers/preview/types';
300
323
  export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
324
+ export { defineConfig } from './config';
325
+ export {
326
+ defineComponent,
327
+ defineCsfComponent,
328
+ defineHandlebarsComponent,
329
+ definePattern,
330
+ defineReactComponent,
331
+ } from './declarations';
332
+ export type {
333
+ CsfDeclarationConfig,
334
+ DeclarationPreview,
335
+ GenericDeclarationConfig,
336
+ GenericPatternDeclarationConfig,
337
+ HandlebarsDeclarationConfig,
338
+ PatternComponentRef,
339
+ ReactDeclarationConfig,
340
+ RendererKind,
341
+ } from './declarations';
301
342
 
302
343
  // Export transformers and types from handoff-core
303
344
  export { Transformers as CoreTransformers, TransformerUtils as CoreTransformerUtils, Types as CoreTypes } from 'handoff-core';
@@ -9,6 +9,7 @@ import { validateHandoffRequirements } from './validation';
9
9
  // Re-exports used by other modules
10
10
  export { readPrevJSONFile, zip, zipAssets } from './archive';
11
11
  export { buildComponents } from './components';
12
+ export { buildPatterns } from './patterns';
12
13
 
13
14
  /**
14
15
  * Run the entire Figma data pipeline:
@@ -0,0 +1,10 @@
1
+ import Handoff from '..';
2
+ import { processPatterns } from '../transformers/preview/pattern/builder';
3
+
4
+ /**
5
+ * Builds pattern previews by composing pre-built component preview HTML.
6
+ * Must be called AFTER buildComponents.
7
+ */
8
+ export const buildPatterns = async (handoff: Handoff) => {
9
+ await processPatterns(handoff);
10
+ };
@@ -10,9 +10,9 @@ transformers/
10
10
  │ ├── component/ # Component builder, CSS/JS bundling, SSR rendering, validation
11
11
  │ ├── component.ts # Component transformer entry point, shared styles processing
12
12
  │ └── types.ts # Preview/component type definitions
13
- ├── plugins/ # Build plugins (SSR render, Handlebars previews, CSF render)
13
+ ├── plugins/ # Build plugins (SSR render, Handlebars previews, CSF render with per-preview/story usage generation)
14
14
  ├── docgen/ # React component documentation generation
15
- ├── utils/ # Build utilities (handlebars, schema, HTML, SVG sprites, Vite logger)
15
+ ├── utils/ # Build utilities (handlebars, schema, HTML, SVG sprites, Vite logger, usage snippet helpers)
16
16
  ├── plugins.ts # Barrel re-exports for plugins
17
17
  ├── vite-config.ts # Shared Vite configuration
18
18
  └── types.ts # Transformer-level type definitions
@@ -261,8 +261,19 @@ export const enrichPropertiesWithDocgen = (
261
261
  export const generateDocsArtifact = async (entry: string, handoff: any): Promise<GeneratedDocs | null> => {
262
262
  try {
263
263
  const tsconfigPath = path.resolve(handoff.workingPath, 'tsconfig.json');
264
- if (!fs.existsSync(tsconfigPath)) {
265
- Logger.warn(`TypeScript config not found at ${tsconfigPath}, using default resolution`);
264
+ const hasTsconfig = fs.existsSync(tsconfigPath);
265
+ const entryExtension = path.extname(entry).toLowerCase();
266
+ const isJavaScriptEntry = ['.js', '.jsx', '.cjs', '.mjs'].includes(entryExtension);
267
+
268
+ if (!hasTsconfig) {
269
+ // For JS-only projects, handoff-docgen currently expects a tsconfig and will fail noisily.
270
+ // Skip docgen gracefully in this case.
271
+ if (isJavaScriptEntry) {
272
+ Logger.debug(`Skipping handoff-docgen for ${entry}: no tsconfig.json found in ${handoff.workingPath}`);
273
+ return null;
274
+ }
275
+ Logger.warn(`TypeScript config not found at ${tsconfigPath}; skipping docs generation for ${entry}.`);
276
+ return null;
266
277
  }
267
278
 
268
279
  const componentDirectory = path.dirname(path.resolve(entry));
@@ -4,32 +4,24 @@ import { startCase } from 'lodash';
4
4
  import path from 'path';
5
5
  import React from 'react';
6
6
  import ReactDOMServer from 'react-dom/server';
7
+ import reactElementToJSXString from 'react-element-to-jsx-string';
7
8
  import { Plugin } from 'vite';
8
9
  import Handoff from '../..';
9
10
  import { Logger } from '../../utils/logger';
11
+ import { generateDocsArtifact, getPropertiesForComponentFromDocs } from '../docgen';
10
12
  import { SlotMetadata, SlotType } from '../preview/component';
11
13
  import { TransformComponentTokensResult } from '../preview/types';
12
14
  import { formatHtml, trimPreview } from '../utils/html';
13
15
  import { buildAndEvaluateModule } from '../utils/module';
14
16
  import { ensureIds } from '../utils/schema';
17
+ import { extractComponentName, generateUsageSnippet } from '../utils/usage';
18
+ import { createCsfStoryPreviews, CsfMeta, getCsfStoryEntries, StoryObject } from '../utils/csf';
15
19
  import { createViteLogger } from '../utils/vite-logger';
16
- import { generateDocsArtifact, getPropertiesForComponentFromDocs } from '../docgen';
17
20
 
18
- type StoryObject = {
19
- name?: string;
20
- args?: Record<string, any>;
21
- argTypes?: Record<string, any>;
22
- render?: (args: Record<string, any>) => any;
21
+ type ComponentImportInfo = {
22
+ componentName: string;
23
+ importStatement: string;
23
24
  };
24
-
25
- type CsfMeta = {
26
- title?: string;
27
- component?: React.ComponentType<any>;
28
- args?: Record<string, any>;
29
- argTypes?: Record<string, any>;
30
- render?: (args: Record<string, any>) => any;
31
- };
32
-
33
25
  const PLUGIN_CONSTANTS = {
34
26
  PLUGIN_NAME: 'vite-plugin-csf-render',
35
27
  SCRIPT_ID: 'script',
@@ -78,15 +70,6 @@ function createSlotMetadata(
78
70
  };
79
71
  }
80
72
 
81
- function getStoryEntries(moduleExports: Record<string, any>): Array<[string, StoryObject]> {
82
- return Object.entries(moduleExports).filter(([key, value]) => {
83
- if (key === 'default' || key === '__esModule') return false;
84
- if (typeof value === 'function') return true;
85
- if (value && typeof value === 'object') return true;
86
- return false;
87
- }) as Array<[string, StoryObject]>;
88
- }
89
-
90
73
  function toReactElement(
91
74
  renderResult: any,
92
75
  meta: CsfMeta,
@@ -107,28 +90,179 @@ function toReactElement(
107
90
  return React.createElement('pre', null, JSON.stringify(args, null, 2));
108
91
  }
109
92
 
93
+ /**
94
+ * Produces the React element for a story, mirroring Storybook's resolution order:
95
+ * story render > meta render > meta.component > JSON fallback.
96
+ */
97
+ function getStoryElement(
98
+ meta: CsfMeta,
99
+ story: StoryObject | ((args: Record<string, any>) => any) | undefined,
100
+ args: Record<string, any>
101
+ ): React.ReactElement {
102
+ const storyRender = typeof story === 'function' ? story : story?.render;
103
+ const render = storyRender || meta.render;
104
+
105
+ if (render) {
106
+ return toReactElement(render(args), meta, args);
107
+ }
108
+
109
+ if (meta.component) {
110
+ return React.createElement(meta.component, args);
111
+ }
112
+
113
+ return React.createElement('pre', null, JSON.stringify(args, null, 2));
114
+ }
115
+
110
116
  function safeRenderToHtml(
111
117
  meta: CsfMeta,
112
- story: StoryObject | ((args: Record<string, any>) => any),
118
+ story: StoryObject | ((args: Record<string, any>) => any) | undefined,
113
119
  args: Record<string, any>
114
120
  ): string {
115
121
  try {
116
- const storyRender = typeof story === 'function' ? story : story.render;
117
- const render = storyRender || meta.render;
122
+ return ReactDOMServer.renderToString(getStoryElement(meta, story, args));
123
+ } catch (error) {
124
+ Logger.warn(`SSR render failed for story, falling back to static placeholder: ${error}`);
125
+ return `<div style="padding:1rem;color:#b91c1c;font-family:monospace;font-size:13px">Render error: ${String(error)}</div>`;
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Converts a story's React element into a JSX source string using the same
131
+ * approach as Storybook's Docs addon.
132
+ */
133
+ function reactElementToUsage(element: React.ReactElement): string | null {
134
+ try {
135
+ return reactElementToJSXString(element, {
136
+ sortProps: false,
137
+ useBooleanShorthandSyntax: true,
138
+ useFragmentShortSyntax: true,
139
+ maxInlineAttributesLineLength: 80,
140
+ });
141
+ } catch (error) {
142
+ Logger.warn(`Failed to convert React element to JSX string: ${error}`);
143
+ return null;
144
+ }
145
+ }
146
+
147
+ function findMatchingBrace(source: string, openIndex: number): number {
148
+ let depth = 0;
118
149
 
119
- if (render) {
120
- return ReactDOMServer.renderToString(toReactElement(render(args), meta, args));
150
+ for (let i = openIndex; i < source.length; i += 1) {
151
+ const char = source[i];
152
+ if (char === '{') depth += 1;
153
+ if (char === '}') {
154
+ depth -= 1;
155
+ if (depth === 0) {
156
+ return i;
157
+ }
121
158
  }
159
+ }
160
+
161
+ return -1;
162
+ }
163
+
164
+ function extractMetaObjectBody(sourceCode: string): string | null {
165
+ // TODO: Regex-based CSF meta parsing can miss complex inline `satisfies` cases.
166
+ // Move this to a TS-AST resolver in handoff-docgen and consume the normalized import info here.
167
+ const inlineMetaMatch = sourceCode.match(/export\s+default\s+({[\s\S]*?})\s*(?:satisfies\s+[^{;\n]+|as\s+[^{;\n]+)?\s*;/m);
168
+ if (inlineMetaMatch?.[1]) {
169
+ return inlineMetaMatch[1];
170
+ }
122
171
 
123
- if (meta.component) {
124
- return ReactDOMServer.renderToString(React.createElement(meta.component, args));
172
+ const exportedMetaRef = sourceCode.match(/export\s+default\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*;/m);
173
+ if (!exportedMetaRef?.[1]) {
174
+ return null;
175
+ }
176
+
177
+ const metaVarName = exportedMetaRef[1];
178
+ const declarationPatterns = [
179
+ new RegExp(`(?:const|let|var)\\s+${metaVarName}\\s*=\\s*`, 'm'),
180
+ new RegExp(`(?:const|let|var)\\s+${metaVarName}\\s*=\\s*[^\\n;]*?\\b(?:satisfies|as)\\b[^\\n;]*?`, 'm'),
181
+ ];
182
+
183
+ for (const pattern of declarationPatterns) {
184
+ const match = pattern.exec(sourceCode);
185
+ if (!match) continue;
186
+ const openIndex = sourceCode.indexOf('{', match.index);
187
+ if (openIndex === -1) continue;
188
+ const closeIndex = findMatchingBrace(sourceCode, openIndex);
189
+ if (closeIndex === -1) continue;
190
+ return sourceCode.slice(openIndex, closeIndex + 1);
191
+ }
192
+
193
+ return null;
194
+ }
195
+
196
+ function resolveComponentImportInfo(sourceCode: string): ComponentImportInfo | null {
197
+ const metaBody = extractMetaObjectBody(sourceCode);
198
+ if (!metaBody) {
199
+ return null;
200
+ }
201
+
202
+ const componentMatch = metaBody.match(/\bcomponent\s*:\s*([A-Za-z_$][A-Za-z0-9_$]*)\b/m);
203
+ if (!componentMatch?.[1]) {
204
+ return null;
205
+ }
206
+
207
+ const localName = componentMatch[1];
208
+
209
+ const defaultImportMatch = sourceCode.match(
210
+ new RegExp(`import\\s+${localName}\\s+from\\s+['"]([^'"]+)['"]`, 'm')
211
+ );
212
+ if (defaultImportMatch?.[1]) {
213
+ return {
214
+ componentName: localName,
215
+ importStatement: `import ${localName} from '${defaultImportMatch[1]}';`,
216
+ };
217
+ }
218
+
219
+ const namedImportRegex = /import\s*{\s*([^}]+)\s*}\s*from\s*['"]([^'"]+)['"]/gm;
220
+ for (const match of sourceCode.matchAll(namedImportRegex)) {
221
+ const specifiers = match[1]
222
+ .split(',')
223
+ .map((specifier) => specifier.trim())
224
+ .filter(Boolean);
225
+ const moduleSpecifier = match[2];
226
+
227
+ for (const specifier of specifiers) {
228
+ const aliasMatch = specifier.match(/^([A-Za-z_$][A-Za-z0-9_$]*)\s+as\s+([A-Za-z_$][A-Za-z0-9_$]*)$/);
229
+ if (aliasMatch && aliasMatch[2] === localName) {
230
+ return {
231
+ componentName: localName,
232
+ importStatement: `import { ${aliasMatch[1]} as ${localName} } from '${moduleSpecifier}';`,
233
+ };
234
+ }
235
+
236
+ if (specifier === localName) {
237
+ return {
238
+ componentName: localName,
239
+ importStatement: `import { ${localName} } from '${moduleSpecifier}';`,
240
+ };
241
+ }
125
242
  }
243
+ }
126
244
 
127
- return ReactDOMServer.renderToString(React.createElement('pre', null, JSON.stringify(args, null, 2)));
128
- } catch (error) {
129
- Logger.warn(`SSR render failed for story, falling back to static placeholder: ${error}`);
130
- return `<div style="padding:1rem;color:#b91c1c;font-family:monospace;font-size:13px">Render error: ${String(error)}</div>`;
245
+ return null;
246
+ }
247
+
248
+ function buildUsageSnippet(
249
+ componentName: string,
250
+ importStatement: string,
251
+ element: React.ReactElement | null,
252
+ properties: Record<string, SlotMetadata>,
253
+ previewValues: Record<string, any>
254
+ ): string {
255
+ const usageBody = element ? reactElementToUsage(element) : null;
256
+ if (usageBody) {
257
+ return `${importStatement}\n\n${usageBody}`;
131
258
  }
259
+
260
+ return generateUsageSnippet({
261
+ componentName,
262
+ properties,
263
+ previewValues,
264
+ importStatement,
265
+ });
132
266
  }
133
267
 
134
268
  function createHtmlDocument(componentId: string, previewTitle: string, renderedHtml: string): string {
@@ -189,7 +323,8 @@ export function csfRenderPlugin(
189
323
  }
190
324
 
191
325
  const meta = (moduleExports.default || {}) as CsfMeta;
192
- const stories = getStoryEntries(moduleExports);
326
+ const stories = getCsfStoryEntries(moduleExports);
327
+ const storyMap = Object.fromEntries(stories);
193
328
 
194
329
  if (stories.length === 0) {
195
330
  Logger.warn(`No named stories found in CSF file for ${componentId}: ${templatePath}`);
@@ -198,9 +333,16 @@ export function csfRenderPlugin(
198
333
 
199
334
  const explicitProperties = componentData.properties || {};
200
335
  const generatedProperties: Record<string, SlotMetadata> = { ...explicitProperties };
336
+ const incomingPreviews = { ...(componentData.previews || {}) };
337
+ const patternPreviews = Object.fromEntries(
338
+ Object.entries(incomingPreviews).filter(([key]) => key.startsWith('__pattern_'))
339
+ );
201
340
 
202
- // Include all named stories as previews and infer properties from args/argTypes
203
- componentData.previews = {};
341
+ // Include all named stories as previews and preserve injected pattern previews.
342
+ componentData.previews = {
343
+ ...createCsfStoryPreviews(moduleExports),
344
+ ...patternPreviews,
345
+ };
204
346
 
205
347
  for (const [storyKey, storyValue] of stories) {
206
348
  const storyObj = (typeof storyValue === 'function' ? {} : storyValue) as StoryObject;
@@ -213,12 +355,6 @@ export function csfRenderPlugin(
213
355
  ...(storyObj.argTypes || {}),
214
356
  };
215
357
 
216
- componentData.previews[storyKey] = {
217
- title: storyObj.name || startCase(storyKey),
218
- values: mergedArgs,
219
- url: '',
220
- };
221
-
222
358
  for (const [argKey, argValue] of Object.entries(mergedArgs)) {
223
359
  if (!generatedProperties[argKey]) {
224
360
  generatedProperties[argKey] = createSlotMetadata(argKey, mergedArgTypes[argKey], argValue);
@@ -236,6 +372,10 @@ export function csfRenderPlugin(
236
372
  const generatedDocs = await generateDocsArtifact(templatePath, handoff);
237
373
  const componentName =
238
374
  (meta.component as any)?.displayName || (meta.component as any)?.name;
375
+ const componentImportInfo = resolveComponentImportInfo(sourceCode);
376
+ const usageComponentName = componentImportInfo?.componentName || componentName || extractComponentName(templatePath);
377
+ const usageImportStatement =
378
+ componentImportInfo?.importStatement || `import ${usageComponentName} from './${usageComponentName}';`;
239
379
  if (generatedDocs && componentName) {
240
380
  const docgenProperties = getPropertiesForComponentFromDocs(
241
381
  generatedDocs,
@@ -285,11 +425,15 @@ export function csfRenderPlugin(
285
425
  componentData.properties = ensureIds(generatedProperties);
286
426
 
287
427
  let lastHtml = '';
288
- for (const [storyKey, storyValue] of stories) {
289
- const preview = componentData.previews[storyKey];
290
- const rendered = safeRenderToHtml(meta, storyValue, preview.values || {});
428
+ for (const [previewKey, preview] of Object.entries(componentData.previews)) {
429
+ const storyKey = preview.sourcePreview && storyMap[preview.sourcePreview]
430
+ ? preview.sourcePreview
431
+ : (storyMap[previewKey] ? previewKey : undefined);
432
+ const storyValue = storyKey ? storyMap[storyKey] : undefined;
433
+ const storyArgs = preview.values || {};
434
+ const rendered = safeRenderToHtml(meta, storyValue, storyArgs);
291
435
  const html = await formatHtml(createHtmlDocument(componentId, preview.title, rendered));
292
- const fileName = `${componentId}-${storyKey}.html`;
436
+ const fileName = `${componentId}-${previewKey}.html`;
293
437
 
294
438
  this.emitFile({
295
439
  type: 'asset',
@@ -298,10 +442,26 @@ export function csfRenderPlugin(
298
442
  });
299
443
  this.emitFile({
300
444
  type: 'asset',
301
- fileName: `${componentId}-${storyKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`,
445
+ fileName: `${componentId}-${previewKey}${PLUGIN_CONSTANTS.INSPECT_SUFFIX}.html`,
302
446
  source: html,
303
447
  });
304
448
 
449
+ // Capture JSX usage for each story so docs can switch usage by selected preview.
450
+ let storyElement: React.ReactElement | null = null;
451
+ try {
452
+ storyElement = getStoryElement(meta, storyValue, storyArgs);
453
+ } catch {
454
+ // Fall back to schema-based generation below.
455
+ }
456
+
457
+ preview.usage = buildUsageSnippet(
458
+ usageComponentName,
459
+ usageImportStatement,
460
+ storyElement,
461
+ componentData.properties || {},
462
+ storyArgs
463
+ );
464
+
305
465
  preview.url = fileName;
306
466
  lastHtml = html;
307
467
  }
@@ -310,6 +470,13 @@ export function csfRenderPlugin(
310
470
  componentData.preview = '';
311
471
  componentData.code = trimPreview(sourceCode);
312
472
  componentData.html = trimPreview(lastHtml);
473
+
474
+ // Keep top-level usage for backwards compatibility (first preview usage).
475
+ const storyKeys = Object.keys(componentData.previews);
476
+ const firstStoryKey = storyKeys[0];
477
+ if (firstStoryKey) {
478
+ componentData.usage = componentData.previews[firstStoryKey].usage || '';
479
+ }
313
480
  },
314
481
  };
315
482
  }
@@ -8,10 +8,10 @@ import { Plugin, normalizePath } from 'vite';
8
8
  import Handoff from '../..';
9
9
  import { Logger } from '../../utils/logger';
10
10
  import {
11
- enrichPropertiesWithDocgen,
12
- generateDocsArtifact,
13
- generatePropertiesFromDocgen,
14
- getPropertiesFromGeneratedDocs,
11
+ enrichPropertiesWithDocgen,
12
+ generateDocsArtifact,
13
+ generatePropertiesFromDocgen,
14
+ getPropertiesFromGeneratedDocs,
15
15
  } from '../docgen';
16
16
  import { SlotMetadata } from '../preview/component';
17
17
  import { TransformComponentTokensResult } from '../preview/types';
@@ -20,6 +20,7 @@ import { formatHtml, trimPreview } from '../utils/html';
20
20
  import { buildAndEvaluateModule } from '../utils/module';
21
21
  import { loadSchemaFromComponent, loadSchemaFromFile } from '../utils/schema-loader';
22
22
  import { slugify } from '../utils/string';
23
+ import { extractComponentName, generateUsageSnippet } from '../utils/usage';
23
24
  import { createViteLogger } from '../utils/vite-logger';
24
25
 
25
26
  /**
@@ -204,8 +205,6 @@ export function ssrRenderPlugin(
204
205
  documentationComponents = {};
205
206
  }
206
207
 
207
- const generatedPreviews: { [key: string]: string } = {};
208
-
209
208
  // Process component instances from documentation
210
209
  // Use figmaComponentId if provided, otherwise skip implicit matching
211
210
  if (componentData.figmaComponentId) {
@@ -219,6 +218,7 @@ export function ssrRenderPlugin(
219
218
  title: variationId,
220
219
  url: '',
221
220
  values: instanceValues,
221
+ usage: '',
222
222
  };
223
223
  }
224
224
  }
@@ -294,8 +294,13 @@ export function ssrRenderPlugin(
294
294
  source: finalHtml,
295
295
  });
296
296
 
297
- generatedPreviews[previewKey] = finalHtml;
298
297
  componentData.previews[previewKey].url = `${componentId}-${previewKey}.html`;
298
+ componentData.previews[previewKey].usage = generateUsageSnippet({
299
+ componentName: extractComponentName(componentPath),
300
+ properties: componentData.properties || {},
301
+ previewValues: previewProps || {},
302
+ templateFileName: path.basename(componentPath),
303
+ });
299
304
  }
300
305
 
301
306
  // Format final HTML and update component data
@@ -304,6 +309,17 @@ export function ssrRenderPlugin(
304
309
  componentData.preview = '';
305
310
  componentData.code = trimPreview(componentSourceCode);
306
311
  componentData.html = trimPreview(finalHtml);
312
+
313
+ // Generate usage snippet from the first preview's values
314
+ const previewKeys = Object.keys(componentData.previews);
315
+ const firstPreviewValues = previewKeys.length > 0 ? componentData.previews[previewKeys[0]].values : {};
316
+ const componentName = extractComponentName(componentPath);
317
+ componentData.usage = generateUsageSnippet({
318
+ componentName,
319
+ properties: componentData.properties || {},
320
+ previewValues: firstPreviewValues,
321
+ templateFileName: path.basename(componentPath),
322
+ });
307
323
  },
308
324
  };
309
325
  }