handoff-app 1.2.0-0 → 1.2.0-2

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 (68) hide show
  1. package/dist/app-builder/build.js +24 -10
  2. package/dist/app-builder/config-diff/index.d.ts +1 -1
  3. package/dist/app-builder/config-diff/registry.d.ts +2 -2
  4. package/dist/app-builder/config-diff/registry.js +2 -2
  5. package/dist/app-builder/config-diff/strategies/pattern.js +14 -1
  6. package/dist/app-builder/config-diff/types.d.ts +15 -1
  7. package/dist/app-builder/paths.js +18 -1
  8. package/dist/app-builder/watchers.js +35 -3
  9. package/dist/app-builder/websocket.js +10 -4
  10. package/dist/cli.js +1 -1
  11. package/dist/config/normalizers/pattern.d.ts +0 -1
  12. package/dist/config/normalizers/pattern.js +0 -3
  13. package/dist/config/runtime.js +10 -4
  14. package/dist/pipeline/patterns.d.ts +3 -1
  15. package/dist/pipeline/patterns.js +2 -2
  16. package/dist/transformers/plugins/csf-render.js +2 -1
  17. package/dist/transformers/plugins/ssr-render.js +2 -1
  18. package/dist/transformers/preview/component/api.d.ts +2 -13
  19. package/dist/transformers/preview/component/api.js +7 -46
  20. package/dist/transformers/preview/component/artifacts.d.ts +4 -0
  21. package/dist/transformers/preview/component/artifacts.js +126 -0
  22. package/dist/transformers/preview/component/builder.js +62 -6
  23. package/dist/transformers/preview/component/css.d.ts +2 -0
  24. package/dist/transformers/preview/component/css.js +25 -9
  25. package/dist/transformers/preview/component/javascript.d.ts +1 -1
  26. package/dist/transformers/preview/component/javascript.js +45 -19
  27. package/dist/transformers/preview/component/summary.d.ts +6 -0
  28. package/dist/transformers/preview/component/summary.js +55 -0
  29. package/dist/transformers/preview/component.js +3 -25
  30. package/dist/transformers/preview/pattern/api.d.ts +4 -0
  31. package/dist/transformers/preview/pattern/api.js +19 -1
  32. package/dist/transformers/preview/pattern/builder.d.ts +9 -1
  33. package/dist/transformers/preview/pattern/builder.js +123 -42
  34. package/dist/transformers/utils/vite-logger.js +4 -4
  35. package/dist/transformers/vite-config.js +2 -0
  36. package/dist/utils/duration.d.ts +2 -0
  37. package/dist/utils/duration.js +6 -0
  38. package/dist/utils/logger.d.ts +42 -5
  39. package/dist/utils/logger.js +135 -15
  40. package/package.json +1 -1
  41. package/src/app/pages/system/pattern/[pattern]/index.tsx +178 -55
  42. package/src/app-builder/build.ts +26 -10
  43. package/src/app-builder/config-diff/index.ts +1 -1
  44. package/src/app-builder/config-diff/registry.ts +3 -3
  45. package/src/app-builder/config-diff/strategies/pattern.ts +17 -2
  46. package/src/app-builder/config-diff/types.ts +16 -1
  47. package/src/app-builder/paths.ts +22 -1
  48. package/src/app-builder/watchers.ts +15 -3
  49. package/src/app-builder/websocket.ts +16 -5
  50. package/src/cli.ts +1 -1
  51. package/src/config/normalizers/pattern.ts +0 -7
  52. package/src/config/runtime.ts +11 -4
  53. package/src/pipeline/patterns.ts +5 -3
  54. package/src/transformers/plugins/csf-render.ts +2 -1
  55. package/src/transformers/plugins/ssr-render.ts +2 -1
  56. package/src/transformers/preview/component/api.ts +2 -50
  57. package/src/transformers/preview/component/artifacts.ts +131 -0
  58. package/src/transformers/preview/component/builder.ts +72 -6
  59. package/src/transformers/preview/component/css.ts +26 -10
  60. package/src/transformers/preview/component/javascript.ts +50 -23
  61. package/src/transformers/preview/component/summary.ts +54 -0
  62. package/src/transformers/preview/component.ts +2 -1
  63. package/src/transformers/preview/pattern/api.ts +17 -0
  64. package/src/transformers/preview/pattern/builder.ts +156 -58
  65. package/src/transformers/utils/vite-logger.ts +4 -4
  66. package/src/transformers/vite-config.ts +2 -0
  67. package/src/utils/duration.ts +3 -0
  68. package/src/utils/logger.ts +143 -16
@@ -1,10 +1,12 @@
1
1
  import Handoff from '..';
2
- import { processPatterns } from '../transformers/preview/pattern/builder';
2
+ import { ProcessPatternsOptions, processPatterns } from '../transformers/preview/pattern/builder';
3
+
4
+ export type { ProcessPatternsOptions };
3
5
 
4
6
  /**
5
7
  * Builds pattern previews by composing pre-built component preview HTML.
6
8
  * Must be called AFTER buildComponents.
7
9
  */
8
- export const buildPatterns = async (handoff: Handoff) => {
9
- await processPatterns(handoff);
10
+ export const buildPatterns = async (handoff: Handoff, options?: ProcessPatternsOptions) => {
11
+ await processPatterns(handoff, options);
10
12
  };
@@ -10,6 +10,7 @@ import Handoff from '../..';
10
10
  import { Logger } from '../../utils/logger';
11
11
  import { generateDocsArtifact, getPropertiesForComponentFromDocs } from '../docgen';
12
12
  import { SlotMetadata, SlotType } from '../preview/component';
13
+ import { MAIN_COMPONENT_CSS_FILE } from '../preview/component/css';
13
14
  import { TransformComponentTokensResult } from '../preview/types';
14
15
  import { formatHtml, trimPreview } from '../utils/html';
15
16
  import { buildAndEvaluateModule } from '../utils/module';
@@ -270,7 +271,7 @@ function createHtmlDocument(componentId: string, previewTitle: string, renderedH
270
271
  <html lang="en">
271
272
  <head>
272
273
  <meta charset="UTF-8" />
273
- <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/main.css" />
274
+ <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${MAIN_COMPONENT_CSS_FILE}" />
274
275
  <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${componentId}.css" />
275
276
  <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/css/preview.css" />
276
277
  <title>${previewTitle}</title>
@@ -14,6 +14,7 @@ import {
14
14
  getPropertiesFromGeneratedDocs,
15
15
  } from '../docgen';
16
16
  import { SlotMetadata } from '../preview/component';
17
+ import { MAIN_COMPONENT_CSS_FILE } from '../preview/component/css';
17
18
  import { TransformComponentTokensResult } from '../preview/types';
18
19
  import { DEFAULT_CLIENT_BUILD_CONFIG, createReactResolvePlugin } from '../utils/build';
19
20
  import { formatHtml, trimPreview } from '../utils/html';
@@ -124,7 +125,7 @@ function generateHtmlDocument(componentId: string, previewTitle: string, rendere
124
125
  <html lang="en">
125
126
  <head>
126
127
  <meta charset="UTF-8" />
127
- <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/main.css" />
128
+ <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${MAIN_COMPONENT_CSS_FILE}" />
128
129
  <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${componentId}.css" />
129
130
  <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/css/preview.css" />
130
131
  <script id="${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}" type="application/json">${JSON.stringify(props)}</script>
@@ -4,7 +4,7 @@ import Handoff from '../../../index';
4
4
  import { ComponentListObject, TransformComponentTokensResult } from '../types';
5
5
  import { getDocumentedPreviews } from './previews';
6
6
 
7
- const sanitizeComponentApiData = (
7
+ export const sanitizeComponentApiData = (
8
8
  component: TransformComponentTokensResult
9
9
  ): TransformComponentTokensResult => {
10
10
  if (!component) return component;
@@ -64,16 +64,6 @@ export const getAPIPath = (handoff: Handoff) => {
64
64
  return apiPath;
65
65
  };
66
66
 
67
- /**
68
- * Build the preview API from the component data
69
- * @param handoff
70
- * @param componentData
71
- */
72
- const writeComponentSummaryAPI = async (handoff: Handoff, componentData: ComponentListObject[]) => {
73
- componentData.sort((a, b) => a.title.localeCompare(b.title));
74
- await fs.writeFile(path.resolve(getAPIPath(handoff), 'components.json'), JSON.stringify(componentData, null, 2));
75
- };
76
-
77
67
  export const writeComponentApi = async (
78
68
  id: string,
79
69
  component: TransformComponentTokensResult,
@@ -110,43 +100,6 @@ export const writeComponentApi = async (
110
100
  await fs.writeFile(outputFilePath, JSON.stringify(sanitizedComponent, null, 2));
111
101
  };
112
102
 
113
- /**
114
- * Update the main component summary API with the new component data
115
- * @param handoff
116
- * @param componentData
117
- */
118
- export const updateComponentSummaryApi = async (handoff: Handoff, componentData: ComponentListObject[], isFullRebuild: boolean = false) => {
119
- if (isFullRebuild) {
120
- // Full rebuild: replace the entire file
121
- await writeComponentSummaryAPI(handoff, componentData);
122
- return;
123
- }
124
-
125
- // Partial update: merge with existing data
126
- const apiPath = path.resolve(getAPIPath(handoff), 'components.json');
127
- let existingData: ComponentListObject[] = [];
128
-
129
- if (fs.existsSync(apiPath)) {
130
- try {
131
- const existing = await fs.readFile(apiPath, 'utf8');
132
- existingData = (JSON.parse(existing) as ComponentListObject[]).map((component) => ({
133
- ...component,
134
- previews: getDocumentedPreviews(component.previews),
135
- }));
136
- } catch {
137
- // Corrupt or missing JSON — treat as empty
138
- existingData = [];
139
- }
140
- }
141
-
142
- // Replace existing entries with same ID
143
- const incomingIds = new Set(componentData.map((c) => c.id));
144
- const merged = [...componentData, ...existingData.filter((c) => !incomingIds.has(c.id))];
145
-
146
- // Always write the file (even if merged is empty)
147
- await writeComponentSummaryAPI(handoff, merged);
148
- };
149
-
150
103
  /**
151
104
  * Read the component API data
152
105
  * @param handoff
@@ -198,5 +151,4 @@ export const readComponentMetadataApi = async (handoff: Handoff, id: string): Pr
198
151
  path: `/api/component/${id}.json`,
199
152
  };
200
153
  };
201
-
202
- export default writeComponentSummaryAPI;
154
+ export default writeComponentApi;
@@ -0,0 +1,131 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handoff from '../../../index';
4
+ import { TransformComponentTokensResult } from '../types';
5
+ import { getDocumentedPreviews } from './previews';
6
+ import { getAPIPath, sanitizeComponentApiData } from './api';
7
+ import { MAIN_COMPONENT_CSS_FILE, SHARED_COMPONENT_CSS_FILE } from './css';
8
+ import { MAIN_COMPONENT_JS_FILE } from './javascript';
9
+
10
+ const COMPONENT_JS_RESERVED_FILES = new Set([MAIN_COMPONENT_JS_FILE]);
11
+ const COMPONENT_CSS_RESERVED_FILES = new Set([MAIN_COMPONENT_CSS_FILE, SHARED_COMPONENT_CSS_FILE]);
12
+
13
+ export const getComponentApiPath = (handoff: Handoff) => path.resolve(getAPIPath(handoff), 'component');
14
+
15
+ const getComponentPreviewKeys = async (handoff: Handoff, componentId: string): Promise<Set<string>> => {
16
+ const runtimeComponent = handoff.runtimeConfig?.entries?.components?.[componentId];
17
+ const previewKeys = new Set<string>();
18
+
19
+ const outputFilePath = path.resolve(getComponentApiPath(handoff), `${componentId}.json`);
20
+ if (fs.existsSync(outputFilePath)) {
21
+ try {
22
+ const existingJson = await fs.readFile(outputFilePath, 'utf8');
23
+ if (existingJson) {
24
+ const existingData = sanitizeComponentApiData(JSON.parse(existingJson) as TransformComponentTokensResult);
25
+ for (const previewKey of Object.keys(existingData?.previews || {})) {
26
+ previewKeys.add(previewKey);
27
+ }
28
+ }
29
+ } catch {
30
+ // Fall back to runtime config below.
31
+ }
32
+ }
33
+
34
+ for (const previewKey of Object.keys(getDocumentedPreviews(runtimeComponent?.previews) || {})) {
35
+ previewKeys.add(previewKey);
36
+ }
37
+
38
+ for (const previewKey of Object.keys(runtimeComponent?.internalPatternPreviews || {})) {
39
+ previewKeys.add(previewKey);
40
+ }
41
+
42
+ return previewKeys;
43
+ };
44
+
45
+ const getArtifactComponentId = (fileName: string, componentIds: Iterable<string>): string | null => {
46
+ let match: string | null = null;
47
+
48
+ for (const componentId of componentIds) {
49
+ if (!fileName.startsWith(`${componentId}-`)) continue;
50
+ if (!match || componentId.length > match.length) {
51
+ match = componentId;
52
+ }
53
+ }
54
+
55
+ return match;
56
+ };
57
+
58
+ export const removeComponentApi = async (handoff: Handoff, id: string): Promise<void> => {
59
+ const outputDirPath = getComponentApiPath(handoff);
60
+ const outputFilePath = path.resolve(outputDirPath, `${id}.json`);
61
+
62
+ if (await fs.pathExists(outputFilePath)) {
63
+ await fs.remove(outputFilePath);
64
+ }
65
+ };
66
+
67
+ export const syncComponentArtifacts = async (handoff: Handoff): Promise<void> => {
68
+ const componentPath = getComponentApiPath(handoff);
69
+ await fs.ensureDir(componentPath);
70
+
71
+ const runtimeComponents = handoff.runtimeConfig?.entries?.components ?? {};
72
+ const runtimeIds = Object.keys(runtimeComponents);
73
+ const entries = await fs.readdir(componentPath);
74
+
75
+ const discoveredArtifactIds = new Set<string>(runtimeIds);
76
+ for (const entry of entries) {
77
+ const parsed = path.parse(entry);
78
+ if (parsed.ext === '.json' || parsed.ext === '.js' || parsed.ext === '.css') {
79
+ if (!COMPONENT_JS_RESERVED_FILES.has(entry) && !COMPONENT_CSS_RESERVED_FILES.has(entry)) {
80
+ discoveredArtifactIds.add(parsed.name);
81
+ }
82
+ }
83
+ }
84
+
85
+ const validFiles = new Set<string>();
86
+ for (const componentId of runtimeIds) {
87
+ validFiles.add(`${componentId}.json`);
88
+
89
+ if (await fs.pathExists(path.resolve(componentPath, `${componentId}.js`))) {
90
+ validFiles.add(`${componentId}.js`);
91
+ }
92
+
93
+ if (await fs.pathExists(path.resolve(componentPath, `${componentId}.css`))) {
94
+ validFiles.add(`${componentId}.css`);
95
+ }
96
+
97
+ const previewKeys = await getComponentPreviewKeys(handoff, componentId);
98
+ for (const previewKey of previewKeys) {
99
+ validFiles.add(`${componentId}-${previewKey}.html`);
100
+ validFiles.add(`${componentId}-${previewKey}-inspect.html`);
101
+ }
102
+ }
103
+
104
+ for (const entry of entries) {
105
+ if (COMPONENT_JS_RESERVED_FILES.has(entry) || COMPONENT_CSS_RESERVED_FILES.has(entry)) {
106
+ continue;
107
+ }
108
+
109
+ const parsed = path.parse(entry);
110
+
111
+ if (parsed.ext === '.json' || parsed.ext === '.js' || parsed.ext === '.css') {
112
+ if (!validFiles.has(entry)) {
113
+ await fs.remove(path.resolve(componentPath, entry));
114
+ }
115
+ continue;
116
+ }
117
+
118
+ if (parsed.ext !== '.html') {
119
+ continue;
120
+ }
121
+
122
+ const ownerId = getArtifactComponentId(entry, discoveredArtifactIds);
123
+ if (!ownerId) {
124
+ continue;
125
+ }
126
+
127
+ if (!validFiles.has(entry)) {
128
+ await fs.remove(path.resolve(componentPath, entry));
129
+ }
130
+ }
131
+ };
@@ -14,11 +14,14 @@ import {
14
14
  updateComponentCacheEntry,
15
15
  } from '../../../cache';
16
16
  import Handoff from '../../../index';
17
+ import { formatDurationMs } from '../../../utils/duration';
17
18
  import { Logger } from '../../../utils/logger';
18
19
  import { ensureIds } from '../../utils/schema';
19
20
  import { ComponentListObject, ComponentType, OptionalPreviewRender, TransformComponentTokensResult } from '../types';
20
- import { readComponentApi, readComponentMetadataApi, updateComponentSummaryApi, writeComponentApi } from './api';
21
+ import { readComponentApi, readComponentMetadataApi, writeComponentApi } from './api';
22
+ import { removeComponentApi, syncComponentArtifacts } from './artifacts';
21
23
  import { getDocumentedPreviews } from './previews';
24
+ import { removeComponentFromSummaryApi, updateComponentSummaryApi } from './summary';
22
25
  import buildComponentCss from './css';
23
26
  import buildPreviews from './html';
24
27
  import buildComponentJs from './javascript';
@@ -83,6 +86,44 @@ const getBuildPreviews = (data: TransformComponentTokensResult): { [key: string]
83
86
  };
84
87
  };
85
88
 
89
+ /** Vite-backed segments in execution order; extend here if new steps are added. */
90
+ const VITE_BUILD_SEGMENTS: {
91
+ key: keyof Pick<ComponentBuildPlan, 'js' | 'css' | 'previews'>;
92
+ label: string;
93
+ }[] = [
94
+ { key: 'js', label: 'script' },
95
+ { key: 'css', label: 'styles' },
96
+ { key: 'previews', label: 'previews' },
97
+ ];
98
+
99
+ const isFullViteComponentBuild = (plan: ComponentBuildPlan): boolean => plan.js && plan.css && plan.previews;
100
+
101
+ const getActiveViteBuildLabels = (plan: ComponentBuildPlan): string[] =>
102
+ VITE_BUILD_SEGMENTS.filter(({ key }) => plan[key]).map(({ label }) => label);
103
+
104
+ /** e.g. "a", "a and b", "a, b, and c" — common CLI copy style. */
105
+ const formatEnglishList = (items: string[]): string => {
106
+ if (items.length === 0) return '';
107
+ if (items.length === 1) return items[0];
108
+ if (items.length === 2) return `${items[0]} and ${items[1]}`;
109
+ return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;
110
+ };
111
+
112
+ const viteBuildStartMessage = (componentId: string, plan: ComponentBuildPlan): string => {
113
+ if (isFullViteComponentBuild(plan)) {
114
+ return `Building component "${componentId}"…`;
115
+ }
116
+ return `Building ${formatEnglishList(getActiveViteBuildLabels(plan))} for component "${componentId}"…`;
117
+ };
118
+
119
+ const viteBuildFinishMessage = (componentId: string, plan: ComponentBuildPlan, elapsedMs: number): string => {
120
+ const duration = formatDurationMs(elapsedMs);
121
+ if (isFullViteComponentBuild(plan)) {
122
+ return `Finished building component "${componentId}" in ${duration}`;
123
+ }
124
+ return `Finished building ${formatEnglishList(getActiveViteBuildLabels(plan))} for component "${componentId}" in ${duration}`;
125
+ };
126
+
86
127
  /**
87
128
  * Returns a normalized build plan describing which component segments need rebuilding.
88
129
  *
@@ -140,6 +181,13 @@ export async function processComponents(
140
181
  const runtimeComponents = handoff.runtimeConfig?.entries?.components ?? {};
141
182
  const allComponentIds = Object.keys(runtimeComponents);
142
183
 
184
+ if (id && !runtimeComponents[id]) {
185
+ await removeComponentApi(handoff, id);
186
+ await removeComponentFromSummaryApi(handoff, id);
187
+ await syncComponentArtifacts(handoff);
188
+ return [];
189
+ }
190
+
143
191
  // Determine which components need building based on cache (when enabled)
144
192
  let componentsToBuild: Set<string>;
145
193
  let cache: BuildCache | null = null;
@@ -193,11 +241,16 @@ export async function processComponents(
193
241
  pruneRemovedComponents(cache, allComponentIds);
194
242
  }
195
243
 
196
- const skippedCount = allComponentIds.length - componentsToBuild.size;
197
- if (skippedCount > 0) {
198
- Logger.info(`Building ${componentsToBuild.size} of ${allComponentIds.length} components (${skippedCount} unchanged)`);
199
- } else if (componentsToBuild.size > 0) {
200
- Logger.info(`Building all ${componentsToBuild.size} components`);
244
+ const total = allComponentIds.length;
245
+ const toBuildCount = componentsToBuild.size;
246
+ const skippedCount = total - toBuildCount;
247
+
248
+ if (total > 0 && toBuildCount === 0) {
249
+ Logger.info(`All ${total} components unchanged; skipping build`);
250
+ } else if (skippedCount > 0 && toBuildCount > 0) {
251
+ Logger.info(`Building ${toBuildCount} of ${total} components (${skippedCount} unchanged)`);
252
+ } else if (toBuildCount > 0) {
253
+ Logger.info(`Building all ${toBuildCount} components`);
201
254
  } else {
202
255
  Logger.info('All components up to date, nothing to build');
203
256
  }
@@ -301,6 +354,13 @@ export async function processComponents(
301
354
  // Components should always have at least one preview variation.
302
355
  ensureDefaultPreview(data);
303
356
 
357
+ const runsViteBuildSteps = buildPlan.js || buildPlan.css || buildPlan.previews;
358
+ let viteBuildStartedAtMs = 0;
359
+ if (runsViteBuildSteps) {
360
+ viteBuildStartedAtMs = Date.now();
361
+ Logger.info(viteBuildStartMessage(runtimeComponentId, buildPlan));
362
+ }
363
+
304
364
  // Build JS if needed (new build, validation missing, or explicit segment request).
305
365
  if (buildPlan.js) {
306
366
  data = await buildComponentJs(data, handoff);
@@ -321,6 +381,11 @@ export async function processComponents(
321
381
  );
322
382
  }
323
383
 
384
+ if (runsViteBuildSteps) {
385
+ const elapsedMs = Date.now() - viteBuildStartedAtMs;
386
+ Logger.info(viteBuildFinishMessage(runtimeComponentId, buildPlan, elapsedMs));
387
+ }
388
+
324
389
  /**
325
390
  * Run validation if explicitly requested and a hook is configured.
326
391
  * This allows custom logic to assess the validity of the generated component data.
@@ -369,6 +434,7 @@ export async function processComponents(
369
434
  // Always merge and write summary file, even if no components processed
370
435
  const isFullRebuild = !id;
371
436
  await updateComponentSummaryApi(handoff, result, isFullRebuild);
437
+ await syncComponentArtifacts(handoff);
372
438
 
373
439
  return result;
374
440
  }
@@ -3,12 +3,16 @@ import path from 'path';
3
3
  import { InlineConfig, build as viteBuild } from 'vite';
4
4
  import { initRuntimeConfig } from '../../../config';
5
5
  import Handoff from '../../../index';
6
+ import { formatDurationMs } from '../../../utils/duration';
6
7
  import { Logger } from '../../../utils/logger';
7
8
  import viteBaseConfig from '../../vite-config';
8
9
  import { getComponentOutputPath } from '../component';
9
10
  import { TransformComponentTokensResult } from '../types';
10
11
  const { pathToFileURL } = require('url');
11
12
 
13
+ export const MAIN_COMPONENT_CSS_FILE = 'main.css';
14
+ export const SHARED_COMPONENT_CSS_FILE = 'shared.css';
15
+
12
16
  /**
13
17
  * Builds a CSS bundle using Vite
14
18
  *
@@ -98,8 +102,16 @@ const buildCssBundle = async ({
98
102
  const buildComponentCss = async (data: TransformComponentTokensResult, handoff: Handoff) => {
99
103
  const id = data.id;
100
104
  Logger.debug(`buildComponentCss`, id);
105
+ const outputPath = getComponentOutputPath(handoff);
106
+ const builtCssPath = path.resolve(outputPath, `${id}.css`);
101
107
  const entry = data.entries?.scss;
108
+
102
109
  if (!entry) {
110
+ // Keep generated output aligned with the current component declaration.
111
+ await fs.remove(builtCssPath);
112
+ delete data['css'];
113
+ delete data['sass'];
114
+ delete data['sharedStyles'];
103
115
  return data;
104
116
  }
105
117
 
@@ -109,10 +121,12 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
109
121
  return data;
110
122
  }
111
123
 
112
- const outputPath = getComponentOutputPath(handoff);
113
-
114
124
  try {
115
125
  if (extension === '.scss' || extension === '.css') {
126
+ // Remove the previous artifact before rebuilding so "no emitted CSS"
127
+ // doesn't silently preserve stale output from an earlier build.
128
+ await fs.remove(builtCssPath);
129
+
116
130
  // Read the original source
117
131
  const sourceContent = await fs.readFile(entry, 'utf8');
118
132
  if (extension === '.scss') {
@@ -139,7 +153,6 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
139
153
  });
140
154
 
141
155
  // Read the built CSS
142
- const builtCssPath = path.resolve(outputPath, `${id}.css`);
143
156
  if (fs.existsSync(builtCssPath)) {
144
157
  const builtCss = await fs.readFile(builtCssPath, 'utf8');
145
158
  data['css'] = builtCss;
@@ -150,8 +163,11 @@ const buildComponentCss = async (data: TransformComponentTokensResult, handoff:
150
163
  if (splitCSS && splitCSS.length > 1) {
151
164
  data['css'] = splitCSS[1];
152
165
  data['sharedStyles'] = splitCSS[0];
153
- await fs.writeFile(path.resolve(outputPath, 'shared.css'), data['sharedStyles']);
166
+ await fs.writeFile(path.resolve(outputPath, SHARED_COMPONENT_CSS_FILE), data['sharedStyles']);
154
167
  }
168
+ } else {
169
+ delete data['css'];
170
+ delete data['sharedStyles'];
155
171
  }
156
172
  }
157
173
  } catch (e) {
@@ -174,10 +190,9 @@ export const buildMainCss = async (handoff: Handoff): Promise<void> => {
174
190
  const entryPath = stat.isDirectory() ? path.resolve(runtimeConfig.entries.scss, 'main.scss') : runtimeConfig.entries.scss;
175
191
 
176
192
  if (entryPath === runtimeConfig.entries.scss || fs.existsSync(entryPath)) {
177
- Logger.success(`Building main CSS file...`);
178
-
193
+ Logger.info(`Building styles for global entry (${MAIN_COMPONENT_CSS_FILE})…`);
194
+ const startedAt = Date.now();
179
195
  try {
180
- // Setup SASS load paths
181
196
  const loadPaths = [
182
197
  path.resolve(handoff.workingPath),
183
198
  path.resolve(handoff.workingPath, handoff.exportsDirectory, handoff.getProjectId()),
@@ -191,12 +206,13 @@ export const buildMainCss = async (handoff: Handoff): Promise<void> => {
191
206
  await buildCssBundle({
192
207
  entry: entryPath,
193
208
  outputPath,
194
- outputFilename: 'main.css',
209
+ outputFilename: MAIN_COMPONENT_CSS_FILE,
195
210
  loadPaths,
196
211
  handoff,
197
212
  });
198
- } catch (e) {
199
- Logger.error(`Failed to build main CSS:`, e);
213
+ Logger.info(`Finished building styles for global entry (${MAIN_COMPONENT_CSS_FILE}) in ${formatDurationMs(Date.now() - startedAt)}`);
214
+ } catch {
215
+ // buildCssBundle already logs failure for the entry path
200
216
  }
201
217
  }
202
218
  }
@@ -3,11 +3,14 @@ import path from 'path';
3
3
  import { InlineConfig, build as viteBuild } from 'vite';
4
4
  import { initRuntimeConfig } from '../../../config';
5
5
  import Handoff from '../../../index';
6
+ import { formatDurationMs } from '../../../utils/duration';
6
7
  import { Logger } from '../../../utils/logger';
7
8
  import viteBaseConfig from '../../vite-config';
8
9
  import { getComponentOutputPath } from '../component';
9
10
  import { TransformComponentTokensResult } from '../types';
10
11
 
12
+ export const MAIN_COMPONENT_JS_FILE = 'main.js';
13
+
11
14
  /**
12
15
  * Builds a JavaScript bundle using Vite
13
16
  *
@@ -54,8 +57,6 @@ const buildJsBundle = async (
54
57
  }
55
58
 
56
59
  await viteBuild(viteConfig);
57
- } catch (e) {
58
- Logger.error(`Failed to build JS for "${outputFilename}":`, e);
59
60
  } finally {
60
61
  // Restore the original NODE_ENV value after vite build completes
61
62
  // This prevents interference with Next.js app building/running processes
@@ -79,25 +80,45 @@ const buildJsBundle = async (
79
80
  */
80
81
  export const buildComponentJs = async (data: TransformComponentTokensResult, handoff: Handoff): Promise<TransformComponentTokensResult> => {
81
82
  const id = data.id;
83
+ const outputPath = getComponentOutputPath(handoff);
84
+ const builtJsPath = path.resolve(outputPath, `${id}.js`);
82
85
  const entry = data.entries?.js;
83
- if (!entry) return data;
84
86
 
85
- const outputPath = getComponentOutputPath(handoff);
87
+ if (!entry) {
88
+ // Keep generated output aligned with the current component declaration.
89
+ await fs.remove(builtJsPath);
90
+ delete data.js;
91
+ delete data['jsCompiled'];
92
+ return data;
93
+ }
86
94
 
87
95
  try {
96
+ // Remove the previous artifact before rebuilding so a no-output build
97
+ // cannot accidentally preserve stale compiled JS.
98
+ await fs.remove(builtJsPath);
99
+
88
100
  const js = await fs.readFile(path.resolve(entry), 'utf8');
89
- await buildJsBundle(
90
- {
91
- entry,
92
- outputPath,
93
- outputFilename: `${id}.js`,
94
- },
95
- handoff
96
- );
101
+ try {
102
+ await buildJsBundle(
103
+ {
104
+ entry,
105
+ outputPath,
106
+ outputFilename: `${id}.js`,
107
+ },
108
+ handoff
109
+ );
110
+ } catch (e) {
111
+ Logger.error(`Failed to bundle JS for component "${id}" (${id}.js):`, e);
112
+ return data;
113
+ }
97
114
 
98
115
  data.js = js;
99
- const compiled = await fs.readFile(path.resolve(outputPath, `${id}.js`), 'utf8');
100
- data['jsCompiled'] = compiled;
116
+ if (await fs.pathExists(builtJsPath)) {
117
+ const compiled = await fs.readFile(builtJsPath, 'utf8');
118
+ data['jsCompiled'] = compiled;
119
+ } else {
120
+ delete data['jsCompiled'];
121
+ }
101
122
  } catch (e) {
102
123
  Logger.error(`JS build failed for component "${id}":`, e);
103
124
  }
@@ -113,21 +134,27 @@ export const buildComponentJs = async (data: TransformComponentTokensResult, han
113
134
  *
114
135
  * @param handoff - The Handoff configuration object containing build settings
115
136
  * @returns A Promise that resolves when the build process is complete
116
- * @throws May throw an error if the build process fails
117
137
  */
118
138
  export const buildMainJS = async (handoff: Handoff): Promise<void> => {
119
139
  const outputPath = getComponentOutputPath(handoff);
120
140
  const runtimeConfig = initRuntimeConfig(handoff)[0];
121
141
 
122
142
  if (runtimeConfig && runtimeConfig.entries.js && fs.existsSync(path.resolve(runtimeConfig.entries.js))) {
123
- await buildJsBundle(
124
- {
125
- entry: runtimeConfig.entries.js,
126
- outputPath,
127
- outputFilename: 'main.js',
128
- },
129
- handoff
130
- );
143
+ Logger.info(`Building script for global entry (${MAIN_COMPONENT_JS_FILE})…`);
144
+ const startedAt = Date.now();
145
+ try {
146
+ await buildJsBundle(
147
+ {
148
+ entry: runtimeConfig.entries.js,
149
+ outputPath,
150
+ outputFilename: MAIN_COMPONENT_JS_FILE,
151
+ },
152
+ handoff
153
+ );
154
+ Logger.info(`Finished building script for global entry (${MAIN_COMPONENT_JS_FILE}) in ${formatDurationMs(Date.now() - startedAt)}`);
155
+ } catch (e) {
156
+ Logger.error(`Failed to build global script (${MAIN_COMPONENT_JS_FILE}):`, e);
157
+ }
131
158
  }
132
159
  };
133
160
 
@@ -0,0 +1,54 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handoff from '../../../index';
4
+ import { ComponentListObject } from '../types';
5
+ import { getDocumentedPreviews } from './previews';
6
+ import { getAPIPath } from './api';
7
+
8
+ const getComponentSummaryPath = (handoff: Handoff): string => path.resolve(getAPIPath(handoff), 'components.json');
9
+
10
+ const writeComponentSummaryAPI = async (handoff: Handoff, componentData: ComponentListObject[]) => {
11
+ componentData.sort((a, b) => a.title.localeCompare(b.title));
12
+ await fs.writeFile(getComponentSummaryPath(handoff), JSON.stringify(componentData, null, 2));
13
+ };
14
+
15
+ const readExistingComponentSummaryApi = async (handoff: Handoff): Promise<ComponentListObject[]> => {
16
+ const apiPath = getComponentSummaryPath(handoff);
17
+ if (!fs.existsSync(apiPath)) {
18
+ return [];
19
+ }
20
+
21
+ try {
22
+ const existing = await fs.readFile(apiPath, 'utf8');
23
+ return (JSON.parse(existing) as ComponentListObject[]).map((component) => ({
24
+ ...component,
25
+ previews: getDocumentedPreviews(component.previews),
26
+ }));
27
+ } catch {
28
+ return [];
29
+ }
30
+ };
31
+
32
+ export const updateComponentSummaryApi = async (
33
+ handoff: Handoff,
34
+ componentData: ComponentListObject[],
35
+ isFullRebuild: boolean = false
36
+ ) => {
37
+ if (isFullRebuild) {
38
+ await writeComponentSummaryAPI(handoff, componentData);
39
+ return;
40
+ }
41
+
42
+ const existingData = await readExistingComponentSummaryApi(handoff);
43
+ const incomingIds = new Set(componentData.map((c) => c.id));
44
+ const merged = [...componentData, ...existingData.filter((c) => !incomingIds.has(c.id))];
45
+ await writeComponentSummaryAPI(handoff, merged);
46
+ };
47
+
48
+ export const removeComponentFromSummaryApi = async (handoff: Handoff, id: string): Promise<void> => {
49
+ const existingData = await readExistingComponentSummaryApi(handoff);
50
+ const filtered = existingData.filter((component) => component.id !== id);
51
+ await writeComponentSummaryAPI(handoff, filtered);
52
+ };
53
+
54
+ export default writeComponentSummaryAPI;
@@ -1,7 +1,8 @@
1
1
  import type { DocAnnotation, TypeNode } from 'handoff-docgen';
2
2
  import path from 'path';
3
3
  import Handoff from '../../index';
4
- import writeComponentSummaryAPI, { getAPIPath } from './component/api';
4
+ import { getAPIPath } from './component/api';
5
+ import writeComponentSummaryAPI from './component/summary';
5
6
  import processComponents from './component/builder';
6
7
  import { buildMainCss } from './component/css';
7
8
  import { buildMainJS } from './component/javascript';