handoff-app 1.1.3 → 1.2.0-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/config/config.template.js +21 -14
  2. package/config/config.template.ts +54 -0
  3. package/config/docs/system.md +2 -0
  4. package/dist/app-builder/build.js +7 -2
  5. package/dist/app-builder/config-diff/index.d.ts +4 -0
  6. package/dist/app-builder/config-diff/index.js +12 -0
  7. package/dist/app-builder/config-diff/registry.d.ts +9 -0
  8. package/dist/app-builder/config-diff/registry.js +30 -0
  9. package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
  10. package/dist/app-builder/config-diff/snapshot.js +43 -0
  11. package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
  12. package/dist/app-builder/config-diff/strategies/pattern.js +93 -0
  13. package/dist/app-builder/config-diff/types.d.ts +19 -0
  14. package/dist/app-builder/config-diff/types.js +2 -0
  15. package/dist/app-builder/watchers/component.d.ts +15 -0
  16. package/dist/app-builder/watchers/component.js +69 -0
  17. package/dist/app-builder/watchers/utils.d.ts +14 -0
  18. package/dist/app-builder/watchers/utils.js +44 -0
  19. package/dist/app-builder/watchers.d.ts +10 -19
  20. package/dist/app-builder/watchers.js +56 -102
  21. package/dist/cache/build-cache.js +14 -5
  22. package/dist/cli/create.js +67 -23
  23. package/dist/cli/eject.js +48 -7
  24. package/dist/cli/make.js +29 -4
  25. package/dist/cli/scaffold.js +60 -19
  26. package/dist/cli.js +1 -1
  27. package/dist/config/helpers.d.ts +7 -0
  28. package/dist/config/helpers.js +31 -0
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +5 -1
  31. package/dist/config/loader.d.ts +10 -1
  32. package/dist/config/loader.js +92 -28
  33. package/dist/config/normalizers/declaration.d.ts +11 -0
  34. package/dist/config/normalizers/declaration.js +119 -0
  35. package/dist/config/normalizers/pattern.d.ts +9 -0
  36. package/dist/config/normalizers/pattern.js +38 -0
  37. package/dist/config/runtime.d.ts +9 -3
  38. package/dist/config/runtime.js +254 -33
  39. package/dist/declarations/helpers.d.ts +6 -0
  40. package/dist/declarations/helpers.js +19 -0
  41. package/dist/declarations/index.d.ts +2 -0
  42. package/dist/declarations/index.js +9 -0
  43. package/dist/declarations/types.d.ts +60 -0
  44. package/dist/declarations/types.js +2 -0
  45. package/dist/index.d.ts +12 -1
  46. package/dist/index.js +34 -5
  47. package/dist/pipeline/index.d.ts +1 -0
  48. package/dist/pipeline/index.js +3 -1
  49. package/dist/pipeline/patterns.d.ts +6 -0
  50. package/dist/pipeline/patterns.js +21 -0
  51. package/dist/transformers/docgen/index.js +12 -2
  52. package/dist/transformers/plugins/csf-render.js +170 -34
  53. package/dist/transformers/plugins/ssr-render.js +18 -2
  54. package/dist/transformers/preview/component/api.js +16 -7
  55. package/dist/transformers/preview/component/builder.js +9 -3
  56. package/dist/transformers/preview/component/css.d.ts +8 -0
  57. package/dist/transformers/preview/component/html.js +14 -5
  58. package/dist/transformers/preview/component/previews.d.ts +9 -0
  59. package/dist/transformers/preview/component/previews.js +10 -0
  60. package/dist/transformers/preview/pattern/api.d.ts +7 -0
  61. package/dist/transformers/preview/pattern/api.js +61 -0
  62. package/dist/transformers/preview/pattern/builder.d.ts +9 -0
  63. package/dist/transformers/preview/pattern/builder.js +86 -0
  64. package/dist/transformers/preview/pattern/html.d.ts +19 -0
  65. package/dist/transformers/preview/pattern/html.js +94 -0
  66. package/dist/transformers/preview/types.d.ts +54 -0
  67. package/dist/transformers/utils/csf.d.ts +16 -0
  68. package/dist/transformers/utils/csf.js +30 -0
  69. package/dist/transformers/utils/index.d.ts +1 -0
  70. package/dist/transformers/utils/index.js +2 -0
  71. package/dist/transformers/utils/module.d.ts +5 -0
  72. package/dist/transformers/utils/module.js +26 -13
  73. package/dist/transformers/utils/usage.d.ts +23 -0
  74. package/dist/transformers/utils/usage.js +294 -0
  75. package/dist/types/config.d.ts +32 -7
  76. package/dist/types/preview.d.ts +2 -0
  77. package/docs/cli.md +3 -3
  78. package/package.json +3 -1
  79. package/src/app/components/Component/PageSliceResolver.tsx +6 -6
  80. package/src/app/components/Component/Preview.tsx +17 -12
  81. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
  82. package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
  83. package/src/app/components/util/index.ts +71 -2
  84. package/src/app/pages/system/pattern/[pattern]/index.tsx +223 -0
  85. package/src/app/pages/system/pattern/index.tsx +14 -0
  86. package/src/app-builder/README.md +37 -9
  87. package/src/app-builder/build.ts +8 -2
  88. package/src/app-builder/config-diff/index.ts +4 -0
  89. package/src/app-builder/config-diff/registry.ts +23 -0
  90. package/src/app-builder/config-diff/snapshot.ts +51 -0
  91. package/src/app-builder/config-diff/strategies/pattern.ts +65 -0
  92. package/src/app-builder/config-diff/types.ts +21 -0
  93. package/src/app-builder/watchers/component.ts +72 -0
  94. package/src/app-builder/watchers/utils.ts +44 -0
  95. package/src/app-builder/watchers.ts +62 -80
  96. package/src/cache/build-cache.ts +14 -5
  97. package/src/cli/create.ts +86 -25
  98. package/src/cli/eject.ts +29 -7
  99. package/src/cli/make.ts +32 -5
  100. package/src/cli/scaffold.ts +68 -21
  101. package/src/cli.ts +1 -1
  102. package/src/config/README.md +10 -1
  103. package/src/config/helpers.ts +43 -0
  104. package/src/config/index.ts +4 -1
  105. package/src/config/loader.ts +110 -31
  106. package/src/config/normalizers/declaration.ts +176 -0
  107. package/src/config/normalizers/pattern.ts +63 -0
  108. package/src/config/runtime.ts +310 -35
  109. package/src/declarations/helpers.ts +36 -0
  110. package/src/declarations/index.ts +19 -0
  111. package/src/declarations/types.ts +70 -0
  112. package/src/index.ts +47 -6
  113. package/src/pipeline/index.ts +1 -0
  114. package/src/pipeline/patterns.ts +10 -0
  115. package/src/transformers/README.md +2 -2
  116. package/src/transformers/docgen/index.ts +13 -2
  117. package/src/transformers/plugins/csf-render.ts +216 -49
  118. package/src/transformers/plugins/ssr-render.ts +23 -7
  119. package/src/transformers/preview/component/api.ts +27 -7
  120. package/src/transformers/preview/component/builder.ts +21 -4
  121. package/src/transformers/preview/component/html.ts +18 -5
  122. package/src/transformers/preview/component/previews.ts +12 -0
  123. package/src/transformers/preview/pattern/api.ts +66 -0
  124. package/src/transformers/preview/pattern/builder.ts +94 -0
  125. package/src/transformers/preview/pattern/html.ts +107 -0
  126. package/src/transformers/preview/types.ts +58 -1
  127. package/src/transformers/utils/csf.ts +48 -0
  128. package/src/transformers/utils/index.ts +3 -0
  129. package/src/transformers/utils/module.ts +33 -18
  130. package/src/transformers/utils/usage.ts +319 -0
  131. package/src/types/README.md +1 -1
  132. package/src/types/config.ts +33 -7
  133. package/src/types/preview.ts +2 -0
  134. package/templates/default/components/button/Button.jsx.tpl +64 -0
  135. package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
  136. package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
  137. package/templates/default/handoff.config.js.tpl +29 -16
  138. package/templates/default/handoff.config.ts.tpl +196 -0
  139. package/templates/default/handoff.config.with-examples.js.tpl +28 -17
  140. package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
  141. package/templates/default/package.json.tpl +1 -3
  142. package/handoff.config.js +0 -90
  143. package/templates/default/components/button/button.js.tpl +0 -62
@@ -0,0 +1,65 @@
1
+ import Handoff from '../../..';
2
+ import { buildPatterns } from '../../../pipeline/patterns';
3
+ import processComponents, { ComponentSegment } from '../../../transformers/preview/component/builder';
4
+ import { Logger } from '../../../utils/logger';
5
+ import { diffMapSnapshots, MapSnapshot, stableStringify } from '../snapshot';
6
+ import { ConfigDiffStrategy } from '../types';
7
+
8
+ /**
9
+ * Snapshots every __pattern_{patternId}_* synthetic preview entry across all
10
+ * components. Outer key is component id, inner key is synthetic preview key.
11
+ */
12
+ const snapshotPatternSynthetics = (handoff: Handoff, patternId: string): MapSnapshot => {
13
+ const components = handoff.runtimeConfig?.entries?.components ?? {};
14
+ const prefix = `__pattern_${patternId}_`;
15
+ const result: MapSnapshot = new Map();
16
+
17
+ for (const [componentId, component] of Object.entries(components)) {
18
+ const previews = component.internalPatternPreviews ?? {};
19
+ const entries = new Map<string, string>();
20
+ for (const [key, preview] of Object.entries(previews)) {
21
+ if (key.startsWith(prefix)) {
22
+ entries.set(key, stableStringify(preview.values));
23
+ }
24
+ }
25
+ if (entries.size > 0) {
26
+ result.set(componentId, entries);
27
+ }
28
+ }
29
+
30
+ return result;
31
+ };
32
+
33
+ export const patternDiffStrategy: ConfigDiffStrategy = {
34
+ kind: 'pattern',
35
+
36
+ capture(handoff, entityId) {
37
+ const oldSnapshot = snapshotPatternSynthetics(handoff, entityId);
38
+
39
+ return {
40
+ async apply(handoff, entityIdAfterReload) {
41
+ const newSnapshot = entityIdAfterReload
42
+ ? snapshotPatternSynthetics(handoff, entityIdAfterReload)
43
+ : new Map();
44
+
45
+ const affectedIds = diffMapSnapshots(oldSnapshot, newSnapshot);
46
+ const displayId = entityIdAfterReload || entityId;
47
+
48
+ if (affectedIds.length > 0) {
49
+ Logger.debug(
50
+ `Pattern "${displayId}" changed: rebuilding ${affectedIds.length} affected component(s): ${affectedIds.join(', ')}`
51
+ );
52
+ for (const componentId of affectedIds) {
53
+ await processComponents(handoff, componentId, ComponentSegment.Previews);
54
+ }
55
+ } else {
56
+ Logger.debug(`Pattern "${displayId}" changed: no synthetic previews affected, skipping component rebuild.`);
57
+ }
58
+ },
59
+ };
60
+ },
61
+
62
+ async finalize(handoff) {
63
+ await buildPatterns(handoff);
64
+ },
65
+ };
@@ -0,0 +1,21 @@
1
+ import Handoff from '../..';
2
+
3
+ /**
4
+ * Handle returned by ConfigDiffStrategy.capture().
5
+ *
6
+ * Captures the pre-reload state in a closure so the snapshot type stays
7
+ * fully encapsulated and the watcher only sees this opaque handle.
8
+ */
9
+ export interface RebuildHandle {
10
+ apply(handoff: Handoff, entityIdAfterReload: string | undefined): Promise<void>;
11
+ }
12
+
13
+ /**
14
+ * Encapsulates the snapshot -> reload -> diff -> selective-rebuild protocol
15
+ * for a single config-file entity kind.
16
+ */
17
+ export interface ConfigDiffStrategy {
18
+ readonly kind: string;
19
+ capture(handoff: Handoff, entityId: string): RebuildHandle;
20
+ finalize(handoff: Handoff): Promise<void>;
21
+ }
@@ -0,0 +1,72 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import Handoff from '../..';
4
+ import { ComponentSegment } from '../../transformers/preview/component/builder';
5
+ import { ComponentListObject } from '../../transformers/preview/types';
6
+ import { normalizePathForCompare } from '../../utils/path';
7
+
8
+ export type RuntimeComponentEntryType = keyof ComponentListObject['entries'];
9
+
10
+ /**
11
+ * Maps configuration entry types to component segments.
12
+ */
13
+ export const mapEntryTypeToSegment = (type: RuntimeComponentEntryType): ComponentSegment | undefined => {
14
+ return {
15
+ js: ComponentSegment.JavaScript,
16
+ scss: ComponentSegment.Style,
17
+ template: ComponentSegment.Previews,
18
+ templates: ComponentSegment.Previews,
19
+ component: ComponentSegment.Previews,
20
+ story: ComponentSegment.Previews,
21
+ }[type];
22
+ };
23
+
24
+ export const resolveComponentIdForChangedFile = (
25
+ handoff: Handoff,
26
+ changedFilePath: string
27
+ ): string | undefined => {
28
+ const normalizedChangedPath = normalizePathForCompare(changedFilePath);
29
+ const runtimeComponents = handoff.runtimeConfig?.entries?.components ?? {};
30
+
31
+ for (const [componentId, componentDef] of Object.entries(runtimeComponents)) {
32
+ const entries = componentDef.entries ?? {};
33
+ for (const entryPath of Object.values(entries)) {
34
+ if (!entryPath) continue;
35
+ const normalizedEntryPath = normalizePathForCompare(entryPath as string);
36
+ if (normalizedEntryPath === normalizedChangedPath) {
37
+ return componentId;
38
+ }
39
+ }
40
+ }
41
+
42
+ // Fallback for legacy path conventions when exact match is unavailable.
43
+ return path.basename(path.dirname(changedFilePath));
44
+ };
45
+
46
+ /**
47
+ * Gets the paths of runtime components to watch.
48
+ *
49
+ * @returns A map of paths to watch and their entry types.
50
+ */
51
+ export const getRuntimeComponentsPathsToWatch = (
52
+ handoff: Handoff
53
+ ): Map<string, RuntimeComponentEntryType> => {
54
+ const result: Map<string, RuntimeComponentEntryType> = new Map();
55
+
56
+ for (const runtimeComponentId of Object.keys(handoff.runtimeConfig?.entries.components ?? {})) {
57
+ const runtimeComponent = handoff.runtimeConfig.entries.components[runtimeComponentId];
58
+ for (const [runtimeComponentEntryType, runtimeComponentEntryPath] of Object.entries(runtimeComponent.entries ?? {})) {
59
+ const normalizedComponentEntryPath = runtimeComponentEntryPath as string;
60
+ if (fs.existsSync(normalizedComponentEntryPath)) {
61
+ const entryType = runtimeComponentEntryType as RuntimeComponentEntryType;
62
+ if (fs.statSync(normalizedComponentEntryPath).isFile()) {
63
+ result.set(path.resolve(normalizedComponentEntryPath), entryType);
64
+ } else {
65
+ result.set(normalizedComponentEntryPath, entryType);
66
+ }
67
+ }
68
+ }
69
+ }
70
+
71
+ return result;
72
+ };
@@ -0,0 +1,44 @@
1
+ import chokidar from 'chokidar';
2
+ import { Logger } from '../../utils/logger';
3
+
4
+ export interface WatcherState {
5
+ busy: boolean;
6
+ pendingHandlers: Map<string, () => Promise<void>>;
7
+ runtimeComponentsWatcher: chokidar.FSWatcher | null;
8
+ runtimeConfigurationWatcher: chokidar.FSWatcher | null;
9
+ componentDirectoriesWatcher: chokidar.FSWatcher | null;
10
+ }
11
+
12
+ /**
13
+ * Schedules a handler for execution with mutual exclusion and per-source
14
+ * coalescing. When the lock is free the handler runs immediately. When busy,
15
+ * only the latest handler per source key is kept.
16
+ */
17
+ export const scheduleHandler = async (
18
+ state: WatcherState,
19
+ source: string,
20
+ handler: () => Promise<void>
21
+ ): Promise<void> => {
22
+ if (state.busy) {
23
+ state.pendingHandlers.set(source, handler);
24
+ return;
25
+ }
26
+
27
+ state.busy = true;
28
+ try {
29
+ await handler();
30
+ while (state.pendingHandlers.size > 0) {
31
+ const entries = Array.from(state.pendingHandlers.entries());
32
+ state.pendingHandlers.clear();
33
+ for (const [pendingSource, pendingHandler] of entries) {
34
+ try {
35
+ await pendingHandler();
36
+ } catch (e) {
37
+ Logger.error(`Unhandled error in scheduled handler for source "${pendingSource}":`, e);
38
+ }
39
+ }
40
+ }
41
+ } finally {
42
+ state.busy = false;
43
+ }
44
+ };
@@ -2,21 +2,24 @@ import chokidar from 'chokidar';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import Handoff from '..';
5
+ import processComponents, { ComponentSegment } from '../transformers/preview/component/builder';
5
6
  import { buildMainCss } from '../transformers/preview/component/css';
6
7
  import { buildMainJS } from '../transformers/preview/component/javascript';
7
- import processComponents, { ComponentSegment } from '../transformers/preview/component/builder';
8
- import { ComponentListObject } from '../transformers/preview/types';
9
8
  import { Logger } from '../utils/logger';
10
9
  import { isPathInside, normalizePathForCompare } from '../utils/path';
11
10
  import { persistClientConfig } from './client-config';
11
+ import { getStrategy, runAllFinalizers } from './config-diff';
12
12
  import { syncPublicFiles } from './paths';
13
+ import {
14
+ getRuntimeComponentsPathsToWatch,
15
+ mapEntryTypeToSegment,
16
+ resolveComponentIdForChangedFile,
17
+ RuntimeComponentEntryType,
18
+ } from './watchers/component';
19
+ import { scheduleHandler, WatcherState } from './watchers/utils';
13
20
 
14
- export interface WatcherState {
15
- debounce: boolean;
16
- runtimeComponentsWatcher: chokidar.FSWatcher | null;
17
- runtimeConfigurationWatcher: chokidar.FSWatcher | null;
18
- componentDirectoriesWatcher: chokidar.FSWatcher | null;
19
- }
21
+ export { getRuntimeComponentsPathsToWatch, mapEntryTypeToSegment } from './watchers/component';
22
+ export type { WatcherState } from './watchers/utils';
20
23
 
21
24
  /**
22
25
  * Watches the working public directory for changes and updates the app.
@@ -28,18 +31,15 @@ export const watchPublicDirectory = (handoff: Handoff, wss: (msg: string) => voi
28
31
  case 'add':
29
32
  case 'change':
30
33
  case 'unlink':
31
- if (!state.debounce) {
32
- state.debounce = true;
34
+ await scheduleHandler(state, 'publicDirectory', async () => {
33
35
  try {
34
36
  Logger.warn('Public directory changed. Handoff will ingest the new data...');
35
37
  await syncPublicFiles(handoff);
36
38
  wss(JSON.stringify({ type: 'reload' }));
37
39
  } catch (e) {
38
40
  Logger.error('Error syncing public directory:', e);
39
- } finally {
40
- state.debounce = false;
41
41
  }
42
- }
42
+ });
43
43
  break;
44
44
  }
45
45
  });
@@ -122,8 +122,7 @@ export const watchGlobalEntries = (handoff: Handoff, state: WatcherState, chokid
122
122
  case 'add':
123
123
  case 'change':
124
124
  case 'unlink':
125
- if (!state.debounce) {
126
- state.debounce = true;
125
+ await scheduleHandler(state, 'globalEntries', async () => {
127
126
  try {
128
127
  Logger.warn('Global entry changed. Rebuilding bundles and components...');
129
128
 
@@ -139,62 +138,23 @@ export const watchGlobalEntries = (handoff: Handoff, state: WatcherState, chokid
139
138
  }
140
139
 
141
140
  await processComponents(handoff);
141
+ await runAllFinalizers(handoff);
142
142
  } catch (e) {
143
143
  Logger.error('Error rebuilding after global entry change:', e);
144
- } finally {
145
- state.debounce = false;
146
144
  }
147
- }
145
+ });
148
146
  break;
149
147
  }
150
148
  });
151
149
  };
152
150
 
153
- /**
154
- * Maps configuration entry types to component segments.
155
- */
156
- export const mapEntryTypeToSegment = (type: keyof ComponentListObject['entries']): ComponentSegment | undefined => {
157
- return {
158
- js: ComponentSegment.JavaScript,
159
- scss: ComponentSegment.Style,
160
- template: ComponentSegment.Previews,
161
- templates: ComponentSegment.Previews,
162
- }[type];
163
- };
164
-
165
- /**
166
- * Gets the paths of runtime components to watch.
167
- *
168
- * @returns A Map of paths to watch and their entry types
169
- */
170
- export const getRuntimeComponentsPathsToWatch = (handoff: Handoff) => {
171
- const result: Map<string, keyof ComponentListObject['entries']> = new Map();
172
-
173
- for (const runtimeComponentId of Object.keys(handoff.runtimeConfig?.entries.components ?? {})) {
174
- const runtimeComponent = handoff.runtimeConfig.entries.components[runtimeComponentId];
175
- for (const [runtimeComponentEntryType, runtimeComponentEntryPath] of Object.entries(runtimeComponent.entries ?? {})) {
176
- const normalizedComponentEntryPath = runtimeComponentEntryPath as string;
177
- if (fs.existsSync(normalizedComponentEntryPath)) {
178
- const entryType = runtimeComponentEntryType as keyof ComponentListObject['entries'];
179
- if (fs.statSync(normalizedComponentEntryPath).isFile()) {
180
- result.set(path.resolve(normalizedComponentEntryPath), entryType);
181
- } else {
182
- result.set(normalizedComponentEntryPath, entryType);
183
- }
184
- }
185
- }
186
- }
187
-
188
- return result;
189
- };
190
-
191
151
  /**
192
152
  * Watches runtime components for changes.
193
153
  */
194
154
  export const watchRuntimeComponents = (
195
155
  handoff: Handoff,
196
156
  state: WatcherState,
197
- runtimeComponentPathsToWatch: Map<string, keyof ComponentListObject['entries']>
157
+ runtimeComponentPathsToWatch: Map<string, RuntimeComponentEntryType>
198
158
  ) => {
199
159
  if (state.runtimeComponentsWatcher) {
200
160
  state.runtimeComponentsWatcher.close();
@@ -202,7 +162,7 @@ export const watchRuntimeComponents = (
202
162
 
203
163
  if (runtimeComponentPathsToWatch.size > 0) {
204
164
  const pathsToWatch = Array.from(runtimeComponentPathsToWatch.keys());
205
- const runtimeComponentEntryTypeByPath = new Map<string, keyof ComponentListObject['entries']>(
165
+ const runtimeComponentEntryTypeByPath = new Map<string, RuntimeComponentEntryType>(
206
166
  Array.from(runtimeComponentPathsToWatch.entries()).map(([watchPath, entryType]) => [normalizePathForCompare(watchPath), entryType])
207
167
  );
208
168
  const configFileSet = new Set(handoff.getConfigFilePaths().map((configPath) => normalizePathForCompare(configPath)));
@@ -220,20 +180,17 @@ export const watchRuntimeComponents = (
220
180
  case 'add':
221
181
  case 'change':
222
182
  case 'unlink':
223
- if (!state.debounce) {
224
- state.debounce = true;
183
+ await scheduleHandler(state, `runtimeComponent:${normalizedFile}`, async () => {
225
184
  try {
226
185
  const entryType = runtimeComponentEntryTypeByPath.get(normalizedFile);
227
- const segmentToUpdate: ComponentSegment = entryType ? mapEntryTypeToSegment(entryType) : undefined;
228
-
229
- const componentDir = path.basename(path.dirname(file));
230
- await processComponents(handoff, componentDir, segmentToUpdate);
186
+ const segmentToUpdate: ComponentSegment | undefined = entryType ? mapEntryTypeToSegment(entryType) : undefined;
187
+ const componentId = resolveComponentIdForChangedFile(handoff, file);
188
+ await processComponents(handoff, componentId, segmentToUpdate);
189
+ await runAllFinalizers(handoff);
231
190
  } catch (e) {
232
191
  Logger.error('Error processing component:', e);
233
- } finally {
234
- state.debounce = false;
235
192
  }
236
- }
193
+ });
237
194
  break;
238
195
  }
239
196
  });
@@ -242,6 +199,11 @@ export const watchRuntimeComponents = (
242
199
 
243
200
  /**
244
201
  * Watches the runtime configuration for changes.
202
+ *
203
+ * Uses the generic {@link ConfigDiffStrategy} registry so that each entity
204
+ * kind (patterns today, potentially themes / slots later) supplies its own
205
+ * snapshot → diff → selective-rebuild logic without the watcher needing to
206
+ * know the details.
245
207
  */
246
208
  export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState) => {
247
209
  if (state.runtimeConfigurationWatcher) {
@@ -251,24 +213,47 @@ export const watchRuntimeConfiguration = (handoff: Handoff, state: WatcherState)
251
213
  if (handoff.getConfigFilePaths().length > 0) {
252
214
  state.runtimeConfigurationWatcher = chokidar.watch(handoff.getConfigFilePaths(), { ignoreInitial: true });
253
215
  state.runtimeConfigurationWatcher.on('all', async (event, file) => {
216
+ const normalizedFile = normalizePathForCompare(file);
254
217
  switch (event) {
255
218
  case 'add':
256
219
  case 'change':
257
220
  case 'unlink':
258
- if (!state.debounce) {
259
- state.debounce = true;
221
+ await scheduleHandler(state, `runtimeConfig:${normalizedFile}`, async () => {
260
222
  try {
261
- file = path.dirname(file);
223
+ const changedFile = file;
224
+
225
+ // Look up the entity this config file belongs to BEFORE reload.
226
+ const entryBefore = handoff.getConfigFileEntry(changedFile);
227
+ const strategy = entryBefore ? getStrategy(entryBefore.kind) : undefined;
228
+ const handle =
229
+ entryBefore && strategy ? strategy.capture(handoff, entryBefore.entityId) : undefined;
230
+
262
231
  handoff.reload();
263
232
  await persistClientConfig(handoff);
264
233
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
265
- await processComponents(handoff, path.basename(file));
234
+ watchRuntimeConfiguration(handoff, state);
235
+
236
+ const entryAfter = handoff.getConfigFileEntry(changedFile);
237
+
238
+ const normalizedChanged = normalizePathForCompare(changedFile);
239
+ const normalizedMainConfig = handoff.getMainConfigFilePath()
240
+ ? normalizePathForCompare(handoff.getMainConfigFilePath() as string)
241
+ : undefined;
242
+
243
+ if (normalizedMainConfig && normalizedChanged === normalizedMainConfig) {
244
+ await processComponents(handoff);
245
+ } else if (handle) {
246
+ await handle.apply(handoff, entryAfter?.entityId);
247
+ } else {
248
+ const effectiveId = entryAfter?.entityId ?? path.basename(path.dirname(changedFile));
249
+ await processComponents(handoff, effectiveId);
250
+ }
251
+
252
+ await runAllFinalizers(handoff);
266
253
  } catch (e) {
267
254
  Logger.error('Error reloading runtime configuration:', e);
268
- } finally {
269
- state.debounce = false;
270
255
  }
271
- }
256
+ });
272
257
  break;
273
258
  }
274
259
  });
@@ -315,14 +300,12 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState,
315
300
 
316
301
  if (!isNewComponent) return;
317
302
 
318
- if (!state.debounce) {
319
- state.debounce = true;
303
+ await scheduleHandler(state, `newComponent:${dirName}`, async () => {
320
304
  try {
321
305
  Logger.warn(`New component detected: ${dirName}. Reloading configuration...`);
322
306
  handoff.reload();
323
307
  knownComponents.add(dirName);
324
308
 
325
- // Update known set with all current components
326
309
  for (const id of Object.keys(handoff.runtimeConfig?.entries?.components ?? {})) {
327
310
  knownComponents.add(id);
328
311
  }
@@ -331,11 +314,10 @@ export const watchComponentDirectories = (handoff: Handoff, state: WatcherState,
331
314
  watchRuntimeComponents(handoff, state, getRuntimeComponentsPathsToWatch(handoff));
332
315
  watchRuntimeConfiguration(handoff, state);
333
316
  await processComponents(handoff, dirName);
317
+ await runAllFinalizers(handoff);
334
318
  } catch (e) {
335
319
  Logger.error('Error processing new component:', e);
336
- } finally {
337
- state.debounce = false;
338
320
  }
339
- }
321
+ });
340
322
  });
341
323
  };
@@ -176,8 +176,8 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): {
176
176
  files.push(entries.scss);
177
177
  componentDirs.add(normalizePathForCompare(path.dirname(entries.scss)));
178
178
  }
179
- // Handle both 'template' (singular) and 'templates' (plural) entry types
180
- const templatePath = entries.template || entries.templates;
179
+ // Handle template-style entries across renderer families
180
+ const templatePath = entries.template || entries.templates || entries.component || entries.story;
181
181
  if (templatePath) {
182
182
  try {
183
183
  const stat = fs.statSync(templatePath);
@@ -198,10 +198,19 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): {
198
198
 
199
199
  // Find the config file path for this component using exact config filename + directory matching.
200
200
  const configPaths = handoff.getConfigFilePaths();
201
- const expectedConfigFileNames = new Set([`${componentId}.json`, `${componentId}.js`, `${componentId}.cjs`]);
201
+ const expectedConfigFileNames = new Set([
202
+ `${componentId}.handoff.ts`,
203
+ `${componentId}.handoff.js`,
204
+ `${componentId}.handoff.cjs`,
205
+ `${componentId}.handoff.json`,
206
+ `${componentId}.json`,
207
+ `${componentId}.js`,
208
+ `${componentId}.cjs`,
209
+ ]);
202
210
  const matchingConfigPath = configPaths.find((configPath) => {
203
211
  const configFileName = path.basename(configPath);
204
- if (!expectedConfigFileNames.has(configFileName)) {
212
+ const isModernDeclaration = /\.handoff\.(ts|js|cjs|json)$/.test(configFileName);
213
+ if (!expectedConfigFileNames.has(configFileName) && !isModernDeclaration) {
205
214
  return false;
206
215
  }
207
216
 
@@ -240,7 +249,7 @@ export async function computeComponentFileStates(
240
249
 
241
250
  let templateDirFiles: Record<string, FileState> | undefined;
242
251
  if (templateDir) {
243
- templateDirFiles = await computeDirectoryState(templateDir, ['.hbs', '.html', '.tsx', '.ts']);
252
+ templateDirFiles = await computeDirectoryState(templateDir, ['.hbs', '.html', '.tsx', '.ts', '.jsx', '.js']);
244
253
  }
245
254
 
246
255
  return { files, templateDirFiles };
package/src/cli/create.ts CHANGED
@@ -21,7 +21,9 @@ interface CreateOptions {
21
21
  projectName: string;
22
22
  figmaProjectId: string;
23
23
  figmaAccessToken: string;
24
- [key: string]: string; // Allow dynamic access for template replacement
24
+ languagePreference: 'typescript' | 'javascript';
25
+ includeExamples: boolean;
26
+ [key: string]: string | boolean; // Allow dynamic access for template replacement
25
27
  }
26
28
 
27
29
  class CreateError extends Error {
@@ -63,23 +65,44 @@ interface TemplateConfig {
63
65
  * Get mandatory templates based on whether examples are included
64
66
  * Uses different handoff config templates for blank vs. with-examples projects
65
67
  */
66
- const getMandatoryTemplates = (includeExamples: boolean): TemplateConfig[] => [
67
- { source: 'package.json.tpl', destination: 'package.json' },
68
- {
69
- source: includeExamples ? 'handoff.config.with-examples.js.tpl' : 'handoff.config.js.tpl',
70
- destination: 'handoff.config.js'
71
- },
72
- { source: 'tsconfig.json.tpl', destination: 'tsconfig.json' },
73
- { source: 'gitignore.tpl', destination: '.gitignore' },
74
- { source: 'env.tpl', destination: '.env' },
75
- { source: 'README.md.tpl', destination: 'README.md' },
76
- ];
77
-
78
- // Example component files (only included when user opts for examples)
79
- const exampleTemplates: TemplateConfig[] = [
80
- { source: 'components/button/Button.tsx.tpl', destination: 'components/button/Button.tsx', isExample: true },
81
- { source: 'components/button/button.js.tpl', destination: 'components/button/button.js', isExample: true },
82
- ];
68
+ const getMandatoryTemplates = (
69
+ includeExamples: boolean,
70
+ languagePreference: 'typescript' | 'javascript'
71
+ ): TemplateConfig[] => {
72
+ const configFormat = languagePreference;
73
+ const configTemplateBaseName = includeExamples ? 'handoff.config.with-examples' : 'handoff.config';
74
+ const configTemplateSource = `${configTemplateBaseName}.${configFormat === 'typescript' ? 'ts' : 'js'}.tpl`;
75
+ const configDestination = `handoff.config.${configFormat === 'typescript' ? 'ts' : 'js'}`;
76
+
77
+ const templates: TemplateConfig[] = [
78
+ { source: 'package.json.tpl', destination: 'package.json' },
79
+ {
80
+ source: configTemplateSource,
81
+ destination: configDestination,
82
+ },
83
+ { source: 'gitignore.tpl', destination: '.gitignore' },
84
+ { source: 'env.tpl', destination: '.env' },
85
+ { source: 'README.md.tpl', destination: 'README.md' },
86
+ ];
87
+
88
+ if (languagePreference === 'typescript') {
89
+ templates.push({ source: 'tsconfig.json.tpl', destination: 'tsconfig.json' });
90
+ }
91
+
92
+ return templates;
93
+ };
94
+
95
+ const getExampleTemplates = (languagePreference: 'typescript' | 'javascript'): TemplateConfig[] => {
96
+ const declarationSuffix = languagePreference === 'typescript' ? 'ts' : 'js';
97
+ const declarationFile = `components/button/button.react.handoff.${declarationSuffix}.tpl`;
98
+ const componentFile =
99
+ languagePreference === 'typescript' ? 'components/button/Button.tsx.tpl' : 'components/button/Button.jsx.tpl';
100
+
101
+ return [
102
+ { source: declarationFile, destination: `components/button/button.handoff.${languagePreference === 'typescript' ? 'ts' : 'js'}`, isExample: true },
103
+ { source: componentFile, destination: `components/button/Button.${languagePreference === 'typescript' ? 'tsx' : 'jsx'}`, isExample: true },
104
+ ];
105
+ };
83
106
 
84
107
  /**
85
108
  * Render a template string by replacing placeholders with values
@@ -89,19 +112,39 @@ const renderTemplate = (content: string, data: CreateOptions): string => {
89
112
  if (!(key in data)) {
90
113
  console.warn(chalk.yellow(`Warning: Unknown template placeholder '{{${key}}}'`));
91
114
  }
92
- return data[key] || '';
115
+ const value = data[key];
116
+ return typeof value === 'string' ? value : '';
93
117
  });
94
118
  };
95
119
 
120
+ const getDependenciesToInstall = (options: CreateOptions): { dependencies: string[]; devDependencies: string[] } => {
121
+ const dependencies = ['handoff-app@latest'];
122
+ const devDependencies: string[] = [];
123
+
124
+ if (options.includeExamples) {
125
+ dependencies.push('react', 'react-dom');
126
+ if (options.languagePreference === 'typescript') {
127
+ devDependencies.push('@types/react', '@types/react-dom');
128
+ }
129
+ }
130
+
131
+ return { dependencies, devDependencies };
132
+ };
133
+
96
134
  /**
97
- * Execute npm install
135
+ * Execute npm install for required dependencies.
98
136
  */
99
- const runNpmInstall = async (projectPath: string): Promise<void> => {
137
+ const installDependencies = async (projectPath: string, options: CreateOptions): Promise<void> => {
138
+ const { dependencies, devDependencies } = getDependenciesToInstall(options);
100
139
  const spinner = p.spinner();
101
140
  spinner.start('Installing dependencies...');
102
141
 
103
142
  return new Promise((resolve, reject) => {
104
- const npmProcess = spawn('npm', ['install'], {
143
+ const installCommand = devDependencies.length
144
+ ? `npm install --save-prefix="^" ${dependencies.join(' ')} && npm install --save-dev --save-prefix="^" ${devDependencies.join(' ')}`
145
+ : `npm install --save-prefix="^" ${dependencies.join(' ')}`;
146
+
147
+ const npmProcess = spawn(installCommand, {
105
148
  cwd: projectPath,
106
149
  stdio: 'pipe',
107
150
  shell: true,
@@ -204,6 +247,22 @@ const create = async (): Promise<void> => {
204
247
 
205
248
  const includeExamples = projectType === 'with-examples';
206
249
 
250
+ const languagePreferenceSelection = await p.select({
251
+ message: 'Preferred project language (declarations, examples, and handoff.config)?',
252
+ options: [
253
+ { value: 'typescript', label: 'TypeScript', hint: 'Uses .handoff.ts and handoff.config.ts' },
254
+ { value: 'javascript', label: 'JavaScript', hint: 'Uses .handoff.js and handoff.config.js' },
255
+ ],
256
+ initialValue: 'typescript',
257
+ });
258
+
259
+ if (p.isCancel(languagePreferenceSelection)) {
260
+ p.cancel('Project creation cancelled.');
261
+ process.exit(0);
262
+ }
263
+
264
+ const languagePreference = languagePreferenceSelection as 'typescript' | 'javascript';
265
+
207
266
  // Get Figma project ID
208
267
  p.log.step(chalk.blue('Figma Configuration'));
209
268
  const figmaProjectId = await p.text({
@@ -234,6 +293,8 @@ const create = async (): Promise<void> => {
234
293
  projectName: projectName as string,
235
294
  figmaProjectId: ((figmaProjectId as string) || '').trim(),
236
295
  figmaAccessToken: ((figmaAccessToken as string) || '').trim(),
296
+ languagePreference,
297
+ includeExamples,
237
298
  };
238
299
 
239
300
  // Create files
@@ -248,8 +309,8 @@ const create = async (): Promise<void> => {
248
309
 
249
310
  // Combine mandatory templates with example templates based on user choice
250
311
  const templates = [
251
- ...getMandatoryTemplates(includeExamples),
252
- ...(includeExamples ? exampleTemplates : []),
312
+ ...getMandatoryTemplates(includeExamples, languagePreference),
313
+ ...(includeExamples ? getExampleTemplates(languagePreference) : []),
253
314
  ];
254
315
 
255
316
  try {
@@ -273,7 +334,7 @@ const create = async (): Promise<void> => {
273
334
  }
274
335
 
275
336
  // Install dependencies
276
- await runNpmInstall(projectPath);
337
+ await installDependencies(projectPath, options);
277
338
 
278
339
  // Success message
279
340
  const nextSteps = [`cd ${projectName}`];