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
@@ -8,6 +8,9 @@ import Layout from '../../../../components/Layout/Main';
8
8
  import { MarkdownComponents } from '../../../../components/Markdown/MarkdownComponents';
9
9
  import PrevNextNav from '../../../../components/Navigation/PrevNextNav';
10
10
  import HeadersType from '../../../../components/Typography/Headers';
11
+ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '../../../../components/ui/accordion';
12
+ import { Badge } from '../../../../components/ui/badge';
13
+ import { JsonTreeView } from '../../../../components/ui/json-tree-view';
11
14
  import {
12
15
  DocumentationProps,
13
16
  fetchPatterns,
@@ -25,6 +28,66 @@ interface PatternPageProps extends DocumentationProps {
25
28
  nextPattern: PatternNavItem | null;
26
29
  }
27
30
 
31
+ const SYNTHETIC_PATTERN_PREVIEW_PREFIX = '__pattern_';
32
+
33
+ const isSyntheticPatternPreview = (previewKey?: string) => Boolean(previewKey?.startsWith(SYNTHETIC_PATTERN_PREVIEW_PREFIX));
34
+
35
+ const formatPreviewLabel = (component: PatternListObject['components'][number], index: number) => {
36
+ const previewKey = component.preview || component.resolvedPreview;
37
+
38
+ if (!previewKey) {
39
+ return {
40
+ title: 'Default preview',
41
+ subtitle: 'Uses the component default preview configuration.',
42
+ rawKey: null,
43
+ };
44
+ }
45
+
46
+ if (isSyntheticPatternPreview(previewKey)) {
47
+ return {
48
+ title: component.args ? 'Custom configuration' : `Generated preview ${index + 1}`,
49
+ subtitle: component.args ? 'Built from inline args for this pattern.' : 'Generated specifically for this pattern composition.',
50
+ rawKey: previewKey,
51
+ };
52
+ }
53
+
54
+ return {
55
+ title: startCase(previewKey),
56
+ subtitle: 'Named preview from the component docs.',
57
+ rawKey: previewKey,
58
+ };
59
+ };
60
+
61
+ const formatArgValue = (value: unknown) => {
62
+ if (typeof value === 'string') {
63
+ return value.length > 24 ? `${value.slice(0, 21)}...` : value;
64
+ }
65
+
66
+ if (typeof value === 'number' || typeof value === 'boolean') {
67
+ return String(value);
68
+ }
69
+
70
+ if (Array.isArray(value)) {
71
+ return `${value.length} item${value.length === 1 ? '' : 's'}`;
72
+ }
73
+
74
+ if (value && typeof value === 'object') {
75
+ return `${Object.keys(value as Record<string, unknown>).length} fields`;
76
+ }
77
+
78
+ if (value === null) {
79
+ return 'null';
80
+ }
81
+
82
+ return 'set';
83
+ };
84
+
85
+ const getArgSummary = (args?: Record<string, unknown>) => {
86
+ if (!args) return [];
87
+
88
+ return Object.entries(args).slice(0, 3);
89
+ };
90
+
28
91
  export async function getStaticPaths() {
29
92
  return {
30
93
  paths: fetchPatterns()?.map((p) => ({ params: { pattern: p.id } })) ?? [],
@@ -74,12 +137,8 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
74
137
  const normalizedBasePath = appBasePath ? `/${appBasePath.replace(/^\/+|\/+$/g, '')}` : '';
75
138
  const patternRoute = (patternId: string) => `${normalizedBasePath}/system/pattern/${patternId}`;
76
139
 
77
- const previousLink = previousPattern
78
- ? { href: patternRoute(previousPattern.id), title: previousPattern.title }
79
- : null;
80
- const nextLink = nextPattern
81
- ? { href: patternRoute(nextPattern.id), title: nextPattern.title }
82
- : null;
140
+ const previousLink = previousPattern ? { href: patternRoute(previousPattern.id), title: previousPattern.title } : null;
141
+ const nextLink = nextPattern ? { href: patternRoute(nextPattern.id), title: nextPattern.title } : null;
83
142
  const invalidComponents = pattern?.components?.filter((comp) => comp.resolved === false) ?? [];
84
143
 
85
144
  useEffect(() => {
@@ -115,7 +174,15 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
115
174
  <Layout config={config} menu={menu} current={current} metadata={metadata}>
116
175
  <div className="flex flex-col gap-3 pb-14">
117
176
  <small className="text-sm font-medium text-sky-600 dark:text-gray-300">Patterns</small>
118
- <HeadersType.H1>{metadata.title}</HeadersType.H1>
177
+ <div className="flex flex-wrap items-center gap-3">
178
+ <HeadersType.H1>{metadata.title}</HeadersType.H1>
179
+ <Badge
180
+ variant="outline"
181
+ className="rounded-full border border-gray-200 px-3 py-1 text-[11px] font-medium text-gray-600 dark:border-gray-700 dark:text-gray-300"
182
+ >
183
+ {pattern.components?.length ?? 0} component{(pattern.components?.length ?? 0) === 1 ? '' : 's'}
184
+ </Badge>
185
+ </div>
119
186
  {metadata.description && (
120
187
  <div className="prose max-w-[800px] text-xl font-light leading-relaxed text-gray-600 dark:text-gray-300">
121
188
  <ReactMarkdown components={MarkdownComponents} rehypePlugins={[rehypeRaw]}>
@@ -126,20 +193,10 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
126
193
  </div>
127
194
 
128
195
  <div className="max-w-[1100px]">
129
- {/* Pattern composition info */}
130
- <div className="mb-4 flex items-center gap-2 text-sm text-gray-500">
131
- <span>{pattern.components?.length ?? 0} component(s)</span>
132
- {pattern.group && (
133
- <>
134
- <span className="text-gray-300">|</span>
135
- <span>{pattern.group}</span>
136
- </>
137
- )}
138
- </div>
139
-
140
196
  {invalidComponents.length > 0 && (
141
197
  <div className="mb-4 rounded-lg border border-amber-300 bg-amber-50 px-4 py-3 text-sm text-amber-900 dark:border-amber-800 dark:bg-amber-950/40 dark:text-amber-200">
142
- {invalidComponents.length} pattern component{invalidComponents.length > 1 ? 's could' : ' could'} not be resolved and {invalidComponents.length > 1 ? 'were' : 'was'} skipped.
198
+ {invalidComponents.length} pattern component{invalidComponents.length > 1 ? 's could' : ' could'} not be resolved and{' '}
199
+ {invalidComponents.length > 1 ? 'were' : 'was'} skipped.
143
200
  </div>
144
201
  )}
145
202
 
@@ -163,9 +220,7 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
163
220
  src={`${normalizedBasePath}/api/pattern/${pattern.url}`}
164
221
  />
165
222
  ) : (
166
- <div className="flex items-center justify-center p-8 text-sm text-gray-500">
167
- No preview available for this pattern.
168
- </div>
223
+ <div className="flex items-center justify-center p-8 text-sm text-gray-500">No preview available for this pattern.</div>
169
224
  )}
170
225
  </div>
171
226
  </div>
@@ -173,42 +228,110 @@ const PatternPage = ({ menu, metadata, current, id, config, previousPattern, nex
173
228
  {/* Component list */}
174
229
  {pattern.components && pattern.components.length > 0 && (
175
230
  <div className="mt-8">
176
- <HeadersType.H3>Components in this pattern</HeadersType.H3>
177
- <div className="mt-4 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
178
- <table className="w-full text-sm">
179
- <thead className="border-b bg-gray-50 dark:bg-gray-800">
180
- <tr>
181
- <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Component</th>
182
- <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Preview</th>
183
- <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Status</th>
184
- <th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">Custom Args</th>
185
- </tr>
186
- </thead>
187
- <tbody>
188
- {pattern.components.map((comp, i) => (
189
- <tr key={`${comp.id}-${i}`} className="border-b last:border-b-0 dark:border-gray-800">
190
- <td className="px-4 py-2 font-mono text-xs">{comp.id}</td>
191
- <td className="px-4 py-2 text-xs text-gray-500">
192
- {comp.preview || comp.resolvedPreview || 'default'}
193
- </td>
194
- <td className="px-4 py-2 text-xs">
195
- {comp.resolved === false ? (
196
- <span className="inline-flex w-fit rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-medium text-amber-800 dark:bg-amber-900/60 dark:text-amber-200">
231
+ <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
232
+ <div>
233
+ <HeadersType.H3>Components in this pattern</HeadersType.H3>
234
+ </div>
235
+ </div>
236
+
237
+ <div className="mt-5 overflow-hidden rounded-xl border border-gray-200 dark:border-gray-800">
238
+ {pattern.components.map((comp, i) => {
239
+ const previewMeta = formatPreviewLabel(comp, i);
240
+ const argSummary = getArgSummary(comp.args);
241
+ const hasArgs = Boolean(comp.args);
242
+
243
+ return (
244
+ <div
245
+ key={`${comp.id}-${i}`}
246
+ className="border-b border-gray-200 bg-white last:border-b-0 dark:border-gray-800 dark:bg-transparent"
247
+ >
248
+ <div className="grid gap-4 px-5 py-4 md:grid-cols-[minmax(0,220px)_minmax(0,1fr)] md:px-6">
249
+ <div className="flex flex-wrap items-center gap-3">
250
+ <span className="text-xs font-medium text-gray-400 dark:text-gray-500">{String(i + 1).padStart(2, '0')}</span>
251
+ <code className="font-mono text-[13px] text-gray-900 dark:text-gray-100">{comp.id}</code>
252
+ {comp.resolved === false && (
253
+ <Badge variant="warning" className="rounded-full px-2.5 py-0.5 text-[11px] font-medium">
197
254
  Unresolved
198
- </span>
255
+ </Badge>
256
+ )}
257
+ </div>
258
+
259
+ <div className="min-w-0">
260
+ {comp.resolved === false ? (
261
+ <div className="rounded-md bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
262
+ Could not resolve component preview.
263
+ </div>
199
264
  ) : (
200
- <span className="inline-flex w-fit rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-medium text-emerald-800 dark:bg-emerald-900/60 dark:text-emerald-200">
201
- OK
202
- </span>
265
+ <>
266
+ <div className="space-y-2">
267
+ <div>
268
+ <p className="text-[10px] font-medium uppercase tracking-[0.1em] text-gray-400 dark:text-gray-500">
269
+ Preview
270
+ </p>
271
+ <p className="mt-1 text-sm font-medium text-gray-900 dark:text-gray-100">{previewMeta.title}</p>
272
+ </div>
273
+
274
+ {previewMeta.rawKey && !hasArgs && (
275
+ <div className="inline-flex max-w-full items-center gap-2 rounded-md bg-gray-50 px-2.5 py-1.5 dark:bg-gray-900">
276
+ <span className="text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">
277
+ Key
278
+ </span>
279
+ <code className="truncate font-mono text-xs text-gray-500 dark:text-gray-400">{previewMeta.rawKey}</code>
280
+ </div>
281
+ )}
282
+ </div>
283
+
284
+ {hasArgs ? (
285
+ <div className="mt-2">
286
+ <Accordion type="single" collapsible className="mt-1">
287
+ <AccordionItem value={`args-${i}`} className="border-none">
288
+ <div className="flex flex-wrap gap-2">
289
+ {argSummary.length > 0 && (
290
+ <dl className="flex flex-wrap gap-2">
291
+ {argSummary.map(([key, value]) => (
292
+ <div
293
+ key={key}
294
+ className="inline-flex max-w-full items-center gap-2 rounded-md bg-gray-50 px-2.5 py-1.5 dark:bg-gray-900"
295
+ >
296
+ <dt className="text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">
297
+ {key}
298
+ </dt>
299
+ <dd className="truncate text-xs text-gray-900 dark:text-gray-100">{formatArgValue(value)}</dd>
300
+ </div>
301
+ ))}
302
+ {Object.keys(comp.args ?? {}).length > argSummary.length && (
303
+ <div className="inline-flex max-w-full items-center rounded-md bg-gray-50 px-2.5 py-1.5 text-xs text-gray-500 dark:bg-gray-900 dark:text-gray-400">
304
+ +{Object.keys(comp.args ?? {}).length - argSummary.length} more
305
+ </div>
306
+ )}
307
+ </dl>
308
+ )}
309
+
310
+ <AccordionTrigger className="inline-flex w-auto flex-none items-center gap-1 rounded-md bg-gray-50 px-2.5 py-1.5 text-[10px] font-medium uppercase tracking-[0.12em] text-gray-400 no-underline hover:bg-gray-100 hover:text-gray-600 hover:no-underline dark:bg-gray-900 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300 [&>svg]:h-3.5 [&>svg]:w-3.5">
311
+ All args
312
+ </AccordionTrigger>
313
+ </div>
314
+ <AccordionContent className="pt-2">
315
+ <JsonTreeView
316
+ data={comp.args}
317
+ defaultExpanded
318
+ maxInitialDepth={1}
319
+ className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900"
320
+ />
321
+ </AccordionContent>
322
+ </AccordionItem>
323
+ </Accordion>
324
+ </div>
325
+ ) : (
326
+ <></>
327
+ )}
328
+ </>
203
329
  )}
204
- </td>
205
- <td className="px-4 py-2 text-xs text-gray-500">
206
- {comp.args ? JSON.stringify(comp.args) : '-'}
207
- </td>
208
- </tr>
209
- ))}
210
- </tbody>
211
- </table>
330
+ </div>
331
+ </div>
332
+ </div>
333
+ );
334
+ })}
212
335
  </div>
213
336
  </div>
214
337
  )}
@@ -105,13 +105,16 @@ const buildApp = async (handoff: Handoff, skipComponents?: boolean): Promise<voi
105
105
  // Build app
106
106
  const buildResult = spawn.sync('npx', ['next', 'build'], {
107
107
  cwd: appPath,
108
- stdio: 'inherit',
108
+ stdio: ['inherit', 'pipe', 'pipe'],
109
109
  env: {
110
110
  ...process.env,
111
111
  NODE_ENV: 'production',
112
112
  },
113
113
  });
114
114
 
115
+ Logger.childProcessBuffer(buildResult.stdout);
116
+ Logger.childProcessBuffer(buildResult.stderr);
117
+
115
118
  if (buildResult.status !== 0) {
116
119
  let errorMsg = `Next.js build failed with exit code ${buildResult.status}`;
117
120
  if (buildResult.error) {
@@ -166,24 +169,32 @@ export const watchApp = async (handoff: Handoff): Promise<void> => {
166
169
  // create empty directory
167
170
  await fs.ensureDir(moduleOutput);
168
171
  }
172
+ Logger.info(`Starting Next.js dev server (Turbopack) at http://${hostname}:${port}…`);
173
+
169
174
  const nextProcess = spawn('npx', ['next', 'dev', '--turbopack', '--port', String(port)], {
170
175
  cwd: appPath,
171
- stdio: 'inherit',
176
+ stdio: ['inherit', 'pipe', 'pipe'],
172
177
  env: {
173
178
  ...process.env,
174
179
  NODE_ENV: 'development',
175
180
  },
176
181
  });
177
- Logger.success(`Ready on http://${hostname}:${port}`);
182
+ Logger.pipeChildStreams(nextProcess.stdout, nextProcess.stderr);
178
183
 
179
184
  nextProcess.on('error', (error) => {
180
- Logger.error(`Next.js dev process error: ${error}`);
185
+ Logger.error(`Next.js dev process failed to start: ${error}`);
181
186
  process.exit(1);
182
187
  });
183
188
 
184
- nextProcess.on('close', (code) => {
185
- Logger.success(`Next.js dev process closed with code ${code}`);
186
- process.exit(code);
189
+ nextProcess.on('close', (code, signal) => {
190
+ if (code === 0) {
191
+ Logger.success(`Next.js dev process exited normally`);
192
+ } else if (signal) {
193
+ Logger.warn(`Next.js dev process stopped (${signal})`);
194
+ } else {
195
+ Logger.error(`Next.js dev process exited with code ${code}`);
196
+ }
197
+ process.exit(code ?? 1);
187
198
  });
188
199
 
189
200
  const wss = await createWebSocketServer(handoff.config.app.ports?.websocket ?? 3001);
@@ -227,16 +238,21 @@ export const devApp = async (handoff: Handoff): Promise<void> => {
227
238
  // Persist client configuration
228
239
  await persistClientConfig(handoff);
229
240
 
230
- // Run
231
- const devResult = spawn.sync('npx', ['next', 'dev', '--turbopack', '--port', String(handoff.config.app.ports?.app ?? 3000)], {
241
+ const devPort = handoff.config.app.ports?.app ?? 3000;
242
+ Logger.info(`Starting Next.js dev server (Turbopack) on port ${devPort}…`);
243
+
244
+ const devResult = spawn.sync('npx', ['next', 'dev', '--turbopack', '--port', String(devPort)], {
232
245
  cwd: appPath,
233
- stdio: 'inherit',
246
+ stdio: ['inherit', 'pipe', 'pipe'],
234
247
  env: {
235
248
  ...process.env,
236
249
  NODE_ENV: 'development',
237
250
  },
238
251
  });
239
252
 
253
+ Logger.childProcessBuffer(devResult.stdout);
254
+ Logger.childProcessBuffer(devResult.stderr);
255
+
240
256
  if (devResult.status !== 0) {
241
257
  let errorMsg = `Next.js dev failed with exit code ${devResult.status}`;
242
258
  if (devResult.error) {
@@ -1,4 +1,4 @@
1
- export type { ConfigDiffStrategy, RebuildHandle } from './types';
1
+ export type { ConfigDiffStrategy, FinalizeContext, RebuildHandle } from './types';
2
2
  export { type MapSnapshot, diffMapSnapshots, stableStringify } from './snapshot';
3
3
  export { patternDiffStrategy } from './strategies/pattern';
4
4
  export { getAllStrategies, getStrategy, runAllFinalizers } from './registry';
@@ -1,6 +1,6 @@
1
1
  import Handoff from '../..';
2
2
  import { patternDiffStrategy } from './strategies/pattern';
3
- import { ConfigDiffStrategy } from './types';
3
+ import { ConfigDiffStrategy, FinalizeContext } from './types';
4
4
 
5
5
  const strategies = new Map<string, ConfigDiffStrategy>([
6
6
  [patternDiffStrategy.kind, patternDiffStrategy],
@@ -16,8 +16,8 @@ export const getAllStrategies = (): ConfigDiffStrategy[] =>
16
16
  * Runs every registered strategy finalizer.
17
17
  * Called after component rebuilds so derived artifacts stay up-to-date.
18
18
  */
19
- export const runAllFinalizers = async (handoff: Handoff): Promise<void> => {
19
+ export const runAllFinalizers = async (handoff: Handoff, context?: FinalizeContext): Promise<void> => {
20
20
  for (const strategy of strategies.values()) {
21
- await strategy.finalize(handoff);
21
+ await strategy.finalize(handoff, context);
22
22
  }
23
23
  };
@@ -1,9 +1,10 @@
1
1
  import Handoff from '../../..';
2
2
  import { buildPatterns } from '../../../pipeline/patterns';
3
3
  import processComponents, { ComponentSegment } from '../../../transformers/preview/component/builder';
4
+ import { getPatternIdsReferencingComponents } from '../../../transformers/preview/pattern/builder';
4
5
  import { Logger } from '../../../utils/logger';
5
6
  import { diffMapSnapshots, MapSnapshot, stableStringify } from '../snapshot';
6
- import { ConfigDiffStrategy } from '../types';
7
+ import { ConfigDiffStrategy, FinalizeContext } from '../types';
7
8
 
8
9
  /**
9
10
  * Snapshots every __pattern_{patternId}_* synthetic preview entry across all
@@ -59,7 +60,21 @@ export const patternDiffStrategy: ConfigDiffStrategy = {
59
60
  };
60
61
  },
61
62
 
62
- async finalize(handoff) {
63
+ async finalize(handoff, context?: FinalizeContext) {
64
+ if (context?.skipPatternFinalizer) {
65
+ return;
66
+ }
67
+
68
+ const componentIds = context?.patternRebuildComponentIds;
69
+ if (componentIds?.length) {
70
+ const patternIds = getPatternIdsReferencingComponents(handoff, componentIds);
71
+ if (patternIds.length === 0) {
72
+ return;
73
+ }
74
+ await buildPatterns(handoff, { onlyPatternIds: new Set(patternIds) });
75
+ return;
76
+ }
77
+
63
78
  await buildPatterns(handoff);
64
79
  },
65
80
  };
@@ -1,5 +1,20 @@
1
1
  import Handoff from '../..';
2
2
 
3
+ /**
4
+ * Optional hints for post-build finalizers (e.g. selective pattern rebuild).
5
+ */
6
+ export type FinalizeContext = {
7
+ /**
8
+ * Only patterns that reference at least one of these component ids are rebuilt.
9
+ * When omitted, all patterns are rebuilt.
10
+ */
11
+ patternRebuildComponentIds?: string[];
12
+ /**
13
+ * When true, skip pattern artifact generation (no component preview HTML changed).
14
+ */
15
+ skipPatternFinalizer?: boolean;
16
+ };
17
+
3
18
  /**
4
19
  * Handle returned by ConfigDiffStrategy.capture().
5
20
  *
@@ -17,5 +32,5 @@ export interface RebuildHandle {
17
32
  export interface ConfigDiffStrategy {
18
33
  readonly kind: string;
19
34
  capture(handoff: Handoff, entityId: string): RebuildHandle;
20
- finalize(handoff: Handoff): Promise<void>;
35
+ finalize(handoff: Handoff, context?: FinalizeContext): Promise<void>;
21
36
  }
@@ -30,6 +30,17 @@ export const getAppPath = (handoff: Handoff): string => {
30
30
  return path.resolve(handoff.modulePath, '.handoff', `${handoff.getProjectId()}`);
31
31
  };
32
32
 
33
+ const mirrorDirectory = async (sourcePath: string, destinationPath: string): Promise<void> => {
34
+ if (!(await fs.pathExists(sourcePath))) {
35
+ await fs.remove(destinationPath);
36
+ return;
37
+ }
38
+
39
+ await fs.remove(destinationPath);
40
+ await fs.ensureDir(path.dirname(destinationPath));
41
+ await fs.copy(sourcePath, destinationPath, { overwrite: true });
42
+ };
43
+
33
44
  /**
34
45
  * Copy the public dir from the working dir to the module dir.
35
46
  */
@@ -37,8 +48,18 @@ export const syncPublicFiles = async (handoff: Handoff): Promise<void> => {
37
48
  const appPath = getAppPath(handoff);
38
49
  const workingPublicPath = getWorkingPublicPath(handoff);
39
50
  if (workingPublicPath) {
40
- await fs.copy(workingPublicPath, path.resolve(appPath, 'public'), {
51
+ const destinationPublicPath = path.resolve(appPath, 'public');
52
+ const sourceApiPath = path.resolve(workingPublicPath, 'api');
53
+ const destinationApiPath = path.resolve(destinationPublicPath, 'api');
54
+
55
+ await fs.copy(workingPublicPath, destinationPublicPath, {
41
56
  overwrite: true,
57
+ filter: (file) => {
58
+ const relativePath = path.relative(workingPublicPath, file);
59
+ return relativePath !== 'api' && !relativePath.startsWith(`api${path.sep}`);
60
+ },
42
61
  });
62
+
63
+ await mirrorDirectory(sourceApiPath, destinationApiPath);
43
64
  }
44
65
  };
@@ -8,7 +8,7 @@ import { buildMainJS } from '../transformers/preview/component/javascript';
8
8
  import { Logger } from '../utils/logger';
9
9
  import { isPathInside, normalizePathForCompare } from '../utils/path';
10
10
  import { persistClientConfig } from './client-config';
11
- import { getStrategy, runAllFinalizers } from './config-diff';
11
+ import { getStrategy, runAllFinalizers, type FinalizeContext } from './config-diff';
12
12
  import { syncPublicFiles } from './paths';
13
13
  import {
14
14
  getRuntimeComponentsPathsToWatch,
@@ -185,8 +185,20 @@ export const watchRuntimeComponents = (
185
185
  const entryType = runtimeComponentEntryTypeByPath.get(normalizedFile);
186
186
  const segmentToUpdate: ComponentSegment | undefined = entryType ? mapEntryTypeToSegment(entryType) : undefined;
187
187
  const componentId = resolveComponentIdForChangedFile(handoff, file);
188
+
189
+ const skipPatterns =
190
+ segmentToUpdate === ComponentSegment.JavaScript ||
191
+ segmentToUpdate === ComponentSegment.Style;
192
+
193
+ let finalizeContext: FinalizeContext | undefined;
194
+ if (skipPatterns) {
195
+ finalizeContext = { skipPatternFinalizer: true };
196
+ } else if (componentId) {
197
+ finalizeContext = { patternRebuildComponentIds: [componentId] };
198
+ }
199
+
188
200
  await processComponents(handoff, componentId, segmentToUpdate);
189
- await runAllFinalizers(handoff);
201
+ await runAllFinalizers(handoff, finalizeContext);
190
202
  } catch (e) {
191
203
  Logger.error('Error processing component:', e);
192
204
  }
@@ -314,7 +326,7 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState,
314
326
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
315
327
  watchRuntimeConfiguration(handoff, state);
316
328
  await processComponents(handoff, dirName);
317
- await runAllFinalizers(handoff);
329
+ await runAllFinalizers(handoff, { patternRebuildComponentIds: [dirName] });
318
330
  } catch (e) {
319
331
  Logger.error('Error processing new component:', e);
320
332
  }
@@ -13,8 +13,21 @@ export interface HandoffWebSocket extends WebSocket {
13
13
  * @returns A function that accepts a message string and broadcasts it to all connected clients.
14
14
  */
15
15
  export const createWebSocketServer = async (port: number = 3001) => {
16
+ Logger.info(`Starting WebSocket server for live reload on port ${port}…`);
17
+
16
18
  const wss = new WebSocket.Server({ port });
17
19
 
20
+ wss.on('listening', () => {
21
+ Logger.info(`WebSocket server listening on ws://localhost:${port} (live reload)`);
22
+ });
23
+
24
+ wss.on('error', (err) => {
25
+ Logger.error(
26
+ `WebSocket server failed${err instanceof Error ? `: ${err.message}` : ''}`,
27
+ err instanceof Error ? err : undefined
28
+ );
29
+ });
30
+
18
31
  // Heartbeat function to mark a connection as alive.
19
32
  const heartbeat = function (this: HandoffWebSocket) {
20
33
  this.isAlive = true;
@@ -25,7 +38,7 @@ export const createWebSocketServer = async (port: number = 3001) => {
25
38
  const extWs = ws as HandoffWebSocket;
26
39
  extWs.isAlive = true;
27
40
  extWs.send(JSON.stringify({ type: 'WELCOME' }));
28
- extWs.on('error', (error) => Logger.error('WebSocket error:', error));
41
+ extWs.on('error', (error) => Logger.error('WebSocket client error:', error));
29
42
  extWs.on('pong', heartbeat);
30
43
  });
31
44
 
@@ -34,7 +47,7 @@ export const createWebSocketServer = async (port: number = 3001) => {
34
47
  wss.clients.forEach((client) => {
35
48
  const extWs = client as HandoffWebSocket;
36
49
  if (!extWs.isAlive) {
37
- Logger.warn('Terminating inactive client');
50
+ Logger.debug('Terminating inactive WebSocket client');
38
51
  return client.terminate();
39
52
  }
40
53
  extWs.isAlive = false;
@@ -47,11 +60,9 @@ export const createWebSocketServer = async (port: number = 3001) => {
47
60
  clearInterval(pingInterval);
48
61
  });
49
62
 
50
- Logger.success(`WebSocket server listening on ws://localhost:${port}`);
51
-
52
63
  // Return a function to broadcast a message to all connected clients
53
64
  return (message: string) => {
54
- Logger.success(`Broadcasting message to ${wss.clients.size} client(s)`);
65
+ Logger.debug(`Broadcasting reload to ${wss.clients.size} WebSocket client(s)`);
55
66
  wss.clients.forEach((client) => {
56
67
  if (client.readyState === WebSocket.OPEN) {
57
68
  client.send(message);
package/src/cli.ts CHANGED
@@ -19,7 +19,7 @@ class HandoffCliError extends Error {
19
19
  * Show the help message
20
20
  */
21
21
  const showVersion = () => {
22
- return 'Handoff App - 1.2.0-0';
22
+ return 'Handoff App - 1.2.0-2';
23
23
  };
24
24
 
25
25
  /**
@@ -5,7 +5,6 @@ type RawPatternDeclaration = Record<string, any>;
5
5
  type NormalizePatternOptions = {
6
6
  declarationPath: string;
7
7
  fallbackId: string;
8
- warn: (message: string) => void;
9
8
  };
10
9
 
11
10
  export const normalizePatternDeclaration = (
@@ -33,12 +32,6 @@ export const normalizePatternDeclaration = (
33
32
 
34
33
  const preview = typeof ref.preview === 'string' ? ref.preview.trim() : undefined;
35
34
 
36
- if (!preview && !ref.args) {
37
- options.warn(
38
- `Pattern "${options.fallbackId}" component ref "${ref.id}" at index ${index} has neither "preview" nor "args". The component's default preview will be used.`
39
- );
40
- }
41
-
42
35
  return {
43
36
  id: ref.id.trim(),
44
37
  preview,
@@ -282,7 +282,6 @@ export const initRuntimeConfig = (handoff: HandoffContext): [runtimeConfig: Runt
282
282
  pattern = normalizePatternDeclaration(rawPattern, {
283
283
  declarationPath: resolvedPatternConfigPath,
284
284
  fallbackId: patternBaseName,
285
- warn: (message) => Logger.warn(message),
286
285
  });
287
286
  } catch (err) {
288
287
  Logger.error(`Failed to read or parse pattern config: ${resolvedPatternConfigPath}`, err);
@@ -387,11 +386,19 @@ const injectPatternPreviews = (result: RuntimeConfig): void => {
387
386
  continue;
388
387
  }
389
388
 
390
- // Case 2: no preview AND no args -> use the component's first existing preview
389
+ // Case 2: no preview AND no args -> use the component's first existing preview (never assume "default")
391
390
  if (!ref.preview && !ref.args) {
392
391
  const previewKeys = Object.keys(component.previews || {});
393
- ref.resolvedPreview = previewKeys[0] || 'default';
394
- ref.resolved = true;
392
+ const firstPreview = previewKeys[0];
393
+ if (firstPreview) {
394
+ ref.resolvedPreview = firstPreview;
395
+ ref.resolved = true;
396
+ } else {
397
+ Logger.warn(
398
+ `Pattern "${patternId}" component ref "${ref.id}" has neither "preview" nor "args", and the component has no previews. This fragment will be skipped.`
399
+ );
400
+ ref.resolved = false;
401
+ }
395
402
  continue;
396
403
  }
397
404