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
@@ -2,6 +2,22 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import Handoff from '../../../index';
4
4
  import { ComponentListObject, TransformComponentTokensResult } from '../types';
5
+ import { getDocumentedPreviews } from './previews';
6
+
7
+ const sanitizeComponentApiData = (
8
+ component: TransformComponentTokensResult
9
+ ): TransformComponentTokensResult => {
10
+ if (!component) return component;
11
+
12
+ const sanitized = {
13
+ ...component,
14
+ previews: getDocumentedPreviews(component.previews),
15
+ };
16
+
17
+ delete sanitized.internalPatternPreviews;
18
+
19
+ return sanitized;
20
+ };
5
21
 
6
22
  /**
7
23
  * Merges values from a source object into a target object, returning a new object.
@@ -66,18 +82,19 @@ export const writeComponentApi = async (
66
82
  ) => {
67
83
  const outputDirPath = path.resolve(getAPIPath(handoff), 'component');
68
84
  const outputFilePath = path.resolve(outputDirPath, `${id}.json`);
85
+ const sanitizedComponent = sanitizeComponentApiData(component);
69
86
 
70
87
  if (fs.existsSync(outputFilePath)) {
71
88
  const existingJson = await fs.readFile(outputFilePath, 'utf8');
72
89
  if (existingJson) {
73
90
  try {
74
- const existingData = JSON.parse(existingJson) as TransformComponentTokensResult;
91
+ const existingData = sanitizeComponentApiData(JSON.parse(existingJson) as TransformComponentTokensResult);
75
92
 
76
93
  // Special case: always allow page to be cleared when undefined
77
94
  // This handles the case where page slices are removed
78
- const finalPreserveKeys = component.page === undefined ? preserveKeys.filter((key) => key !== 'page') : preserveKeys;
95
+ const finalPreserveKeys = sanitizedComponent.page === undefined ? preserveKeys.filter((key) => key !== 'page') : preserveKeys;
79
96
 
80
- const mergedData = updateObject(existingData, component, finalPreserveKeys);
97
+ const mergedData = sanitizeComponentApiData(updateObject(existingData, sanitizedComponent, finalPreserveKeys));
81
98
  await fs.writeFile(outputFilePath, JSON.stringify(mergedData, null, 2));
82
99
  return;
83
100
  } catch (_) {
@@ -90,7 +107,7 @@ export const writeComponentApi = async (
90
107
  fs.mkdirSync(outputDirPath, { recursive: true });
91
108
  }
92
109
 
93
- await fs.writeFile(outputFilePath, JSON.stringify(component, null, 2));
110
+ await fs.writeFile(outputFilePath, JSON.stringify(sanitizedComponent, null, 2));
94
111
  };
95
112
 
96
113
  /**
@@ -112,7 +129,10 @@ export const updateComponentSummaryApi = async (handoff: Handoff, componentData:
112
129
  if (fs.existsSync(apiPath)) {
113
130
  try {
114
131
  const existing = await fs.readFile(apiPath, 'utf8');
115
- existingData = JSON.parse(existing);
132
+ existingData = (JSON.parse(existing) as ComponentListObject[]).map((component) => ({
133
+ ...component,
134
+ previews: getDocumentedPreviews(component.previews),
135
+ }));
116
136
  } catch {
117
137
  // Corrupt or missing JSON — treat as empty
118
138
  existingData = [];
@@ -140,7 +160,7 @@ export const readComponentApi = async (handoff: Handoff, id: string): Promise<Tr
140
160
  try {
141
161
  const existingJson = await fs.readFile(outputFilePath, 'utf8');
142
162
  if (existingJson) {
143
- return JSON.parse(existingJson) as TransformComponentTokensResult;
163
+ return sanitizeComponentApiData(JSON.parse(existingJson) as TransformComponentTokensResult);
144
164
  }
145
165
  } catch (_) {
146
166
  // Unable to parse existing file
@@ -174,7 +194,7 @@ export const readComponentMetadataApi = async (handoff: Handoff, id: string): Pr
174
194
  categories: componentData.categories ? componentData.categories : [],
175
195
  tags: componentData.tags ? componentData.tags : [],
176
196
  properties: componentData.properties,
177
- previews: componentData.previews,
197
+ previews: getDocumentedPreviews(componentData.previews),
178
198
  path: `/api/component/${id}.json`,
179
199
  };
180
200
  };
@@ -16,8 +16,9 @@ import {
16
16
  import Handoff from '../../../index';
17
17
  import { Logger } from '../../../utils/logger';
18
18
  import { ensureIds } from '../../utils/schema';
19
- import { ComponentListObject, ComponentType, TransformComponentTokensResult } from '../types';
19
+ import { ComponentListObject, ComponentType, OptionalPreviewRender, TransformComponentTokensResult } from '../types';
20
20
  import { readComponentApi, readComponentMetadataApi, updateComponentSummaryApi, writeComponentApi } from './api';
21
+ import { getDocumentedPreviews } from './previews';
21
22
  import buildComponentCss from './css';
22
23
  import buildPreviews from './html';
23
24
  import buildComponentJs from './javascript';
@@ -75,6 +76,13 @@ const ensureDefaultPreview = (data: TransformComponentTokensResult): void => {
75
76
  }
76
77
  };
77
78
 
79
+ const getBuildPreviews = (data: TransformComponentTokensResult): { [key: string]: OptionalPreviewRender } => {
80
+ return {
81
+ ...(data?.previews || {}),
82
+ ...(data?.internalPatternPreviews || {}),
83
+ };
84
+ };
85
+
78
86
  /**
79
87
  * Returns a normalized build plan describing which component segments need rebuilding.
80
88
  *
@@ -269,10 +277,11 @@ export async function processComponents(
269
277
  data.css = existingData.css;
270
278
  data.sass = existingData.sass;
271
279
  }
272
- // If we're not building previews, preserve pre-existing HTML, code snippet, and previews.
280
+ // If we're not building previews, preserve pre-existing HTML, code snippet, usage, and previews.
273
281
  if (!buildPlan.previews) {
274
282
  data.html = existingData.html;
275
283
  data.code = existingData.code;
284
+ data.usage = existingData.usage;
276
285
  data.previews = existingData.previews;
277
286
  }
278
287
  /**
@@ -302,7 +311,14 @@ export async function processComponents(
302
311
  }
303
312
  // Build previews (HTML, snapshots, etc) if needed.
304
313
  if (buildPlan.previews) {
305
- data = await buildPreviews(data, handoff, components);
314
+ data = await buildPreviews(
315
+ {
316
+ ...data,
317
+ previews: getBuildPreviews(data),
318
+ },
319
+ handoff,
320
+ components
321
+ );
306
322
  }
307
323
 
308
324
  /**
@@ -317,6 +333,7 @@ export async function processComponents(
317
333
  // Ensure that every property within the properties array/object contains an 'id' field.
318
334
  // This guarantees unique identification for property entries, which is useful for updates and API consumers.
319
335
  data.properties = ensureIds(data.properties);
336
+ data.previews = getDocumentedPreviews(data.previews);
320
337
 
321
338
  // Write the updated component data to the API file for external access and caching.
322
339
  //console.log('data', data);
@@ -374,7 +391,7 @@ const buildComponentSummary = (id: string, data: TransformComponentTokensResult)
374
391
  categories: data.categories ? data.categories : [],
375
392
  tags: data.tags ? data.tags : [],
376
393
  properties: data.properties,
377
- previews: data.previews,
394
+ previews: getDocumentedPreviews(data.previews),
378
395
  path: `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${id}.json`,
379
396
  };
380
397
  };
@@ -1,6 +1,7 @@
1
1
  import react from '@vitejs/plugin-react';
2
2
  import { Types as CoreTypes } from 'handoff-core';
3
3
  import { InlineConfig, build as viteBuild } from 'vite';
4
+ import { RendererKind } from '../../../declarations/types';
4
5
  import Handoff from '../../../index';
5
6
  import { Logger } from '../../../utils/logger';
6
7
  import { csfRenderPlugin, handlebarsPreviewsPlugin, ssrRenderPlugin } from '../../plugins';
@@ -8,6 +9,18 @@ import viteBaseConfig from '../../vite-config';
8
9
  import { getComponentOutputPath } from '../component';
9
10
  import { TransformComponentTokensResult } from '../types';
10
11
 
12
+ const resolveRenderer = (data: TransformComponentTokensResult): RendererKind | undefined => {
13
+ const templatePath = data.entries?.template || '';
14
+ return (
15
+ data.renderer ||
16
+ (data.entries?.story || templatePath.match(/\.stories\.(jsx|tsx|js|ts)$/) ? 'csf' : undefined) ||
17
+ (data.entries?.component || (templatePath.includes('.tsx') && !templatePath.match(/\.stories\.(jsx|tsx|js|ts)$/))
18
+ ? 'react'
19
+ : undefined) ||
20
+ (templatePath.includes('.hbs') ? 'handlebars' : undefined)
21
+ );
22
+ };
23
+
11
24
  /**
12
25
  * Builds previews for components using Vite and Handlebars.
13
26
  *
@@ -29,13 +42,13 @@ export const buildPreviews = async (
29
42
  ): Promise<TransformComponentTokensResult> => {
30
43
  if (!data.entries?.template) return data;
31
44
 
45
+ const resolvedRenderer = resolveRenderer(data);
46
+
32
47
  const plugins = [
33
48
  ...(viteBaseConfig.plugins || []),
34
- ...(data.entries.template.includes('.hbs') ? [handlebarsPreviewsPlugin(data, components, handoff)] : []),
35
- ...(data.entries.template.match(/\.stories\.(jsx|tsx)$/) ? [csfRenderPlugin(data, components, handoff)] : []),
36
- ...(data.entries.template.includes('.tsx') && !data.entries.template.match(/\.stories\.(jsx|tsx)$/)
37
- ? [react(), ssrRenderPlugin(data, components, handoff)]
38
- : []),
49
+ ...(resolvedRenderer === 'handlebars' ? [handlebarsPreviewsPlugin(data, components, handoff)] : []),
50
+ ...(resolvedRenderer === 'csf' ? [csfRenderPlugin(data, components, handoff)] : []),
51
+ ...(resolvedRenderer === 'react' ? [react(), ssrRenderPlugin(data, components, handoff)] : []),
39
52
  ];
40
53
 
41
54
  // Store the current NODE_ENV value before vite build
@@ -0,0 +1,12 @@
1
+ import type { OptionalPreviewRender } from '../types';
2
+
3
+ /**
4
+ * Returns previews exposed in docs/API; synthetic pattern previews (`__pattern_*`) are omitted.
5
+ */
6
+ export const getDocumentedPreviews = (
7
+ previews?: { [key: string]: OptionalPreviewRender }
8
+ ): { [key: string]: OptionalPreviewRender } => {
9
+ return Object.fromEntries(
10
+ Object.entries(previews || {}).filter(([key]) => !key.startsWith('__pattern_'))
11
+ );
12
+ };
@@ -0,0 +1,66 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handoff from '../../../index';
4
+ import { PatternListObject, PatternObject } from '../types';
5
+
6
+ export const getPatternAPIPath = (handoff: Handoff): string => {
7
+ const patternPath = path.resolve(handoff.workingPath, 'public/api/pattern');
8
+ if (!fs.existsSync(patternPath)) {
9
+ fs.mkdirSync(patternPath, { recursive: true });
10
+ }
11
+ return patternPath;
12
+ };
13
+
14
+ export const writePatternApi = async (
15
+ id: string,
16
+ pattern: PatternObject,
17
+ handoff: Handoff
18
+ ): Promise<void> => {
19
+ const outputDir = getPatternAPIPath(handoff);
20
+ const outputFilePath = path.resolve(outputDir, `${id}.json`);
21
+ await fs.writeFile(outputFilePath, JSON.stringify(pattern, null, 2));
22
+ };
23
+
24
+ export const writePatternHtml = async (
25
+ id: string,
26
+ html: string,
27
+ handoff: Handoff
28
+ ): Promise<void> => {
29
+ const outputDir = getPatternAPIPath(handoff);
30
+ const outputFilePath = path.resolve(outputDir, `${id}.html`);
31
+ await fs.writeFile(outputFilePath, html);
32
+ };
33
+
34
+ export const writePatternSummaryApi = async (
35
+ handoff: Handoff,
36
+ patterns: PatternListObject[]
37
+ ): Promise<void> => {
38
+ const apiPath = path.resolve(handoff.workingPath, 'public/api');
39
+ if (!fs.existsSync(apiPath)) {
40
+ fs.mkdirSync(apiPath, { recursive: true });
41
+ }
42
+ patterns.sort((a, b) => a.title.localeCompare(b.title));
43
+ await fs.writeFile(
44
+ path.resolve(apiPath, 'patterns.json'),
45
+ JSON.stringify(patterns, null, 2)
46
+ );
47
+ };
48
+
49
+ export const syncPatternArtifacts = async (
50
+ handoff: Handoff,
51
+ validIds: Iterable<string>
52
+ ): Promise<void> => {
53
+ const validIdSet = new Set(validIds);
54
+ const patternPath = getPatternAPIPath(handoff);
55
+ const entries = await fs.readdir(patternPath);
56
+
57
+ for (const entry of entries) {
58
+ const parsed = path.parse(entry);
59
+ const isPatternArtifact = parsed.ext === '.json' || parsed.ext === '.html';
60
+ if (!isPatternArtifact) continue;
61
+
62
+ if (!validIdSet.has(parsed.name)) {
63
+ await fs.remove(path.resolve(patternPath, entry));
64
+ }
65
+ }
66
+ };
@@ -0,0 +1,94 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handoff from '../../../index';
4
+ import { Logger } from '../../../utils/logger';
5
+ import { PatternListObject } from '../types';
6
+ import { syncPatternArtifacts, writePatternApi, writePatternHtml, writePatternSummaryApi } from './api';
7
+ import { composePatternHtml } from './html';
8
+
9
+ /**
10
+ * Process all pattern declarations and compose their HTML from pre-built
11
+ * component preview files. This function must run AFTER buildComponents
12
+ * so that all component preview HTML files already exist on disk.
13
+ */
14
+ export async function processPatterns(handoff: Handoff): Promise<PatternListObject[]> {
15
+ const runtimePatterns = handoff.runtimeConfig?.entries?.patterns ?? {};
16
+ const patternIds = Object.keys(runtimePatterns);
17
+ const result: PatternListObject[] = [];
18
+
19
+ if (patternIds.length === 0) {
20
+ await syncPatternArtifacts(handoff, []);
21
+ await writePatternSummaryApi(handoff, result);
22
+ return result;
23
+ }
24
+
25
+ Logger.info(`Building ${patternIds.length} pattern(s)...`);
26
+
27
+ const componentOutputDir = path.resolve(handoff.workingPath, 'public/api/component');
28
+ const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
29
+
30
+ for (const patternId of patternIds) {
31
+ const pattern = runtimePatterns[patternId];
32
+ const fragments: { componentId: string; html: string }[] = [];
33
+ let hasErrors = false;
34
+
35
+ for (let i = 0; i < pattern.components.length; i++) {
36
+ const ref = pattern.components[i];
37
+
38
+ if (ref.resolved === false && !ref.resolvedPreview) {
39
+ hasErrors = true;
40
+ continue;
41
+ }
42
+
43
+ const previewKey = ref.resolvedPreview || ref.preview || 'default';
44
+ const htmlFileName = `${ref.id}-${previewKey}.html`;
45
+ const htmlFilePath = path.resolve(componentOutputDir, htmlFileName);
46
+
47
+ if (!fs.existsSync(htmlFilePath)) {
48
+ const error =
49
+ `Pattern "${patternId}" component[${i}] ("${ref.id}") preview file not found: ${htmlFileName}. ` +
50
+ `Ensure the component has a preview named "${previewKey}".`;
51
+ Logger.warn(error);
52
+ ref.resolved = false;
53
+ hasErrors = true;
54
+ continue;
55
+ }
56
+
57
+ ref.resolved = true;
58
+ const html = await fs.readFile(htmlFilePath, 'utf8');
59
+ fragments.push({ componentId: ref.id, html });
60
+ }
61
+
62
+ if (fragments.length === 0) {
63
+ Logger.warn(`Pattern "${patternId}" produced no fragments. Skipping.`);
64
+ continue;
65
+ }
66
+
67
+ if (hasErrors) {
68
+ Logger.warn(`Pattern "${patternId}" has missing components but will be composed from available fragments.`);
69
+ }
70
+
71
+ const composedHtml = composePatternHtml(patternId, pattern.title, fragments, basePath);
72
+ const patternUrl = `${patternId}.html`;
73
+
74
+ const patternData: PatternListObject = {
75
+ ...pattern,
76
+ url: patternUrl,
77
+ path: `${basePath}/api/pattern/${patternId}.json`,
78
+ };
79
+
80
+ await writePatternHtml(patternId, composedHtml, handoff);
81
+ await writePatternApi(patternId, patternData, handoff);
82
+
83
+ result.push(patternData);
84
+ Logger.debug(`Built pattern: ${patternId} (${fragments.length} component(s))`);
85
+ }
86
+
87
+ await syncPatternArtifacts(handoff, result.map((pattern) => pattern.id));
88
+ await writePatternSummaryApi(handoff, result);
89
+ Logger.info(`Finished building ${result.length} pattern(s).`);
90
+
91
+ return result;
92
+ }
93
+
94
+ export default processPatterns;
@@ -0,0 +1,107 @@
1
+ import escape from 'lodash/escape';
2
+ import { parse } from 'node-html-parser';
3
+
4
+ /**
5
+ * Composes multiple component preview HTML documents into a single-page HTML.
6
+ *
7
+ * Each component preview may be a React SSR document (with hydration scripts
8
+ * and a `#root` / `#__APP_PROPS__` pair) or a static Handlebars document.
9
+ *
10
+ * This function namespaces the element ids per-fragment so multiple React
11
+ * components can coexist and hydrate independently on the same page.
12
+ *
13
+ * Each hydration bundle gets its own `<script type="module">` tag for full
14
+ * module-level isolation. This is critical because esbuild bundles React
15
+ * into each script using module-scoped `var` declarations, and `hydrateRoot`
16
+ * is asynchronous — sharing a single module scope would cause later bundles
17
+ * to overwrite earlier React instances before hydration completes.
18
+ */
19
+ export const composePatternHtml = (
20
+ _patternId: string,
21
+ patternTitle: string,
22
+ fragments: { componentId: string; html: string }[],
23
+ basePath: string
24
+ ): string => {
25
+ const cssHrefs = new Set<string>();
26
+ const bodyParts: string[] = [];
27
+ const scriptTags: string[] = [];
28
+
29
+ cssHrefs.add(`${basePath}/api/component/main.css`);
30
+ cssHrefs.add(`${basePath}/assets/css/preview.css`);
31
+
32
+ const seenComponents = new Set<string>();
33
+
34
+ for (let i = 0; i < fragments.length; i++) {
35
+ const { componentId, html } = fragments[i];
36
+
37
+ if (!seenComponents.has(componentId)) {
38
+ seenComponents.add(componentId);
39
+ cssHrefs.add(`${basePath}/api/component/${componentId}.css`);
40
+ }
41
+
42
+ const doc = parse(html);
43
+ const suffix = `_p${i}`;
44
+ const namespacedRootId = `root${suffix}`;
45
+ const namespacedPropsId = `__APP_PROPS__${suffix}`;
46
+
47
+ // -- Extract props JSON (React SSR) ------------------------------------
48
+ const propsScript = doc.querySelector('#__APP_PROPS__');
49
+ let propsContent = '{}';
50
+ if (propsScript) {
51
+ propsContent = propsScript.textContent || '{}';
52
+ propsScript.remove();
53
+ }
54
+
55
+ // -- Extract and rewrite hydration scripts (React SSR) -----------------
56
+ // Each script becomes its own <script type="module"> for full isolation.
57
+ const moduleScripts = doc.querySelectorAll('script[type="module"]');
58
+ for (const script of moduleScripts) {
59
+ let code = script.textContent || '';
60
+ if (code.trim()) {
61
+ code = code
62
+ .replace(/__APP_PROPS__/g, namespacedPropsId)
63
+ .replace(/getElementById\(\s*["']root["']\s*\)/g, `getElementById("${namespacedRootId}")`);
64
+ scriptTags.push(` <script type="module">\n${code}\n </script>`);
65
+ }
66
+ script.remove();
67
+ }
68
+
69
+ // -- Namespace the root element (React SSR) ----------------------------
70
+ const rootEl = doc.querySelector('#root');
71
+ if (rootEl) {
72
+ rootEl.setAttribute('id', namespacedRootId);
73
+ }
74
+
75
+ // -- Extract body content ----------------------------------------------
76
+ const bodyEl = doc.querySelector('body');
77
+ const bodyContent = bodyEl
78
+ ? bodyEl.innerHTML.trim()
79
+ : rootEl
80
+ ? rootEl.outerHTML
81
+ : doc.innerHTML || '';
82
+
83
+ bodyParts.push(
84
+ ` <div class="handoff-pattern-block" data-component="${componentId}" data-fragment="${i}">\n` +
85
+ ` <script id="${namespacedPropsId}" type="application/json">${propsContent}</script>\n` +
86
+ ` ${bodyContent}\n` +
87
+ ` </div>`
88
+ );
89
+ }
90
+
91
+ const linkTags = Array.from(cssHrefs)
92
+ .map((href) => ` <link rel="stylesheet" href="${href}" />`)
93
+ .join('\n');
94
+
95
+ return `<!DOCTYPE html>
96
+ <html lang="en">
97
+ <head>
98
+ <meta charset="UTF-8" />
99
+ ${linkTags}
100
+ <title>${escape(patternTitle)}</title>
101
+ </head>
102
+ <body>
103
+ ${bodyParts.join('\n')}
104
+ ${scriptTags.join('\n')}
105
+ </body>
106
+ </html>`;
107
+ };
@@ -1,5 +1,6 @@
1
1
  import type { GeneratedDocs } from 'handoff-docgen';
2
2
  import { Card } from '../../app/components/Component/Cards';
3
+ import type { RendererKind } from '../../declarations/types';
3
4
  import { ValidationResult } from '../../types/preview';
4
5
  import { Filter } from '../../utils/filter';
5
6
  import { SlotMetadata } from './component';
@@ -108,15 +109,31 @@ export type ComponentObject = {
108
109
  scss?: string;
109
110
  /** Optional path to component template file (if available) */
110
111
  template?: string;
112
+ /** Optional path to React component source for preview rendering */
113
+ component?: string;
114
+ /** Optional path to CSF story file */
115
+ story?: string;
116
+ /** Optional path to schema file (if available) */
117
+ schema?: string;
118
+ /** Optional path to templates directory */
119
+ templates?: string;
111
120
  };
121
+ /** Optional explicit renderer id */
122
+ renderer?: RendererKind;
112
123
  /** Schema describing the expected properties (props/slots) for the component */
113
124
  properties: { [key: string]: SlotMetadata };
114
125
  /** Mapping of preview variations with values and titles for each (used to render sample states) */
115
126
  previews: { [key: string]: OptionalPreviewRender };
127
+ /** Internal previews generated only for pattern composition, not public docs */
128
+ internalPatternPreviews?: { [key: string]: OptionalPreviewRender };
116
129
  /** Optional array of high-level categories for search/filtering */
117
130
  categories?: string[];
118
131
  /** Optional array of tags for search/filtering (e.g. "primary", "interactive") */
119
132
  tags?: string[];
133
+ /** Optional markdown strings describing recommended usage patterns */
134
+ should_do?: string[];
135
+ /** Optional markdown strings describing anti-patterns to avoid */
136
+ should_not_do?: string[];
120
137
  /** Optional source Figma file or node URL for the component */
121
138
  figma?: string;
122
139
  /** Optional canonical Figma component name or ID (used for matching back to design tokens) */
@@ -125,7 +142,7 @@ export type ComponentObject = {
125
142
  page?: ComponentPageDefinition;
126
143
  /** Optional additional options for preview and transformer behaviors */
127
144
  options?: ComponentOptions;
128
- }
145
+ };
129
146
 
130
147
  export type ComponentListObject = {
131
148
  id: string;
@@ -151,17 +168,23 @@ export type TransformComponentTokensResult = {
151
168
  css?: string;
152
169
  sass?: string;
153
170
  sharedStyles?: string;
171
+ usage?: string;
154
172
  title?: string;
155
173
  description?: string;
156
174
  previews?: { [key: string]: OptionalPreviewRender };
175
+ internalPatternPreviews?: { [key: string]: OptionalPreviewRender };
157
176
  properties?: { [key: string]: SlotMetadata };
158
177
  variant?: Record<string, string>;
159
178
  entries?: {
160
179
  js?: string;
161
180
  scss?: string;
162
181
  template?: string;
182
+ component?: string;
183
+ story?: string;
163
184
  schema?: string;
185
+ templates?: string;
164
186
  };
187
+ renderer?: RendererKind;
165
188
  options?: {
166
189
  preview?: {
167
190
  groupBy?: string;
@@ -176,8 +199,42 @@ export type OptionalPreviewRender = {
176
199
  title: string;
177
200
  values: { [key: string]: string | string[] | any };
178
201
  url: string;
202
+ usage?: string;
203
+ /** Optional source preview/story key used to derive this preview */
204
+ sourcePreview?: string;
179
205
  };
180
206
 
181
207
  export interface TransformedPreviewComponents {
182
208
  [key: string]: TransformComponentTokensResult[];
183
209
  }
210
+
211
+ // ---------------------------------------------------------------------------
212
+ // Pattern types
213
+ // ---------------------------------------------------------------------------
214
+
215
+ export type PatternComponentEntry = {
216
+ /** Component id reference */
217
+ id: string;
218
+ /** Named preview variant to use (optional if args provided) */
219
+ preview?: string;
220
+ /** Arg overrides (optional if preview provided) */
221
+ args?: Record<string, any>;
222
+ /** Preview key resolved during injectPatternPreviews (set at build time) */
223
+ resolvedPreview?: string;
224
+ /** Whether the pattern entry resolved to a usable component preview */
225
+ resolved?: boolean;
226
+ };
227
+
228
+ export type PatternObject = {
229
+ title: string;
230
+ description?: string;
231
+ group?: string;
232
+ tags?: string[];
233
+ components: PatternComponentEntry[];
234
+ url?: string;
235
+ };
236
+
237
+ export type PatternListObject = {
238
+ id: string;
239
+ path: string;
240
+ } & PatternObject;
@@ -0,0 +1,48 @@
1
+ import { startCase } from 'lodash';
2
+ import { OptionalPreviewRender } from '../preview/types';
3
+
4
+ export type StoryObject = {
5
+ name?: string;
6
+ args?: Record<string, any>;
7
+ argTypes?: Record<string, any>;
8
+ render?: (args: Record<string, any>) => any;
9
+ };
10
+
11
+ export type CsfMeta = {
12
+ title?: string;
13
+ component?: any;
14
+ args?: Record<string, any>;
15
+ argTypes?: Record<string, any>;
16
+ render?: (args: Record<string, any>) => any;
17
+ };
18
+
19
+ export function getCsfStoryEntries(moduleExports: Record<string, any>): Array<[string, StoryObject]> {
20
+ return Object.entries(moduleExports).filter(([key, value]) => {
21
+ if (key === 'default' || key === '__esModule') return false;
22
+ if (typeof value === 'function') return true;
23
+ if (value && typeof value === 'object') return true;
24
+ return false;
25
+ }) as Array<[string, StoryObject]>;
26
+ }
27
+
28
+ export function createCsfStoryPreviews(
29
+ moduleExports: Record<string, any>
30
+ ): Record<string, OptionalPreviewRender> {
31
+ const meta = (moduleExports.default || {}) as CsfMeta;
32
+ const stories = getCsfStoryEntries(moduleExports);
33
+ const previews: Record<string, OptionalPreviewRender> = {};
34
+
35
+ for (const [storyKey, storyValue] of stories) {
36
+ const storyObj = (typeof storyValue === 'function' ? {} : storyValue) as StoryObject;
37
+ previews[storyKey] = {
38
+ title: storyObj.name || startCase(storyKey),
39
+ values: {
40
+ ...(meta.args || {}),
41
+ ...(storyObj.args || {}),
42
+ },
43
+ url: '',
44
+ };
45
+ }
46
+
47
+ return previews;
48
+ }
@@ -17,6 +17,9 @@ export * from './build';
17
17
  // Module utilities
18
18
  export * from './module';
19
19
 
20
+ // CSF utilities
21
+ export * from './csf';
22
+
20
23
  // Handlebars utilities
21
24
  export * from './handlebars';
22
25