handoff-app 1.1.0 → 1.1.2-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 (39) hide show
  1. package/Changelog.md +58 -0
  2. package/README.md +7 -0
  3. package/dist/app-builder/build.js +20 -11
  4. package/dist/app-builder/watchers.js +8 -9
  5. package/dist/cache/build-cache.js +25 -10
  6. package/dist/cli.js +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/transformers/plugins/handlebars-previews.js +17 -5
  9. package/dist/transformers/preview/component/builder.js +16 -1
  10. package/dist/transformers/utils/handlebars.d.ts +4 -2
  11. package/dist/transformers/utils/handlebars.js +10 -5
  12. package/dist/transformers/utils/html.js +15 -3
  13. package/dist/types/config.d.ts +25 -0
  14. package/dist/utils/path.d.ts +15 -0
  15. package/dist/utils/path.js +31 -0
  16. package/docs/api.md +65 -71
  17. package/package.json +1 -1
  18. package/src/app/components/Component/ComponentLists.tsx +5 -4
  19. package/src/app/components/Navigation/MainNav.tsx +3 -5
  20. package/src/app/components/Navigation/SideNav.tsx +4 -7
  21. package/src/app/components/SideNav/Custom.tsx +5 -3
  22. package/src/app/components/ui/mobile-nav.tsx +10 -7
  23. package/src/app/components/util/index.ts +2 -2
  24. package/src/app/lib/utils.ts +13 -0
  25. package/src/app/next.config.mjs +21 -13
  26. package/src/app/pages/system/component/[component]/index.tsx +1 -1
  27. package/src/app-builder/build.ts +21 -11
  28. package/src/app-builder/watchers.ts +9 -8
  29. package/src/cache/build-cache.ts +29 -11
  30. package/src/cli.ts +1 -1
  31. package/src/index.ts +1 -1
  32. package/src/transformers/plugins/handlebars-previews.ts +22 -6
  33. package/src/transformers/preview/component/builder.ts +17 -1
  34. package/src/transformers/utils/handlebars.ts +17 -9
  35. package/src/transformers/utils/html.ts +16 -3
  36. package/src/types/config.ts +25 -0
  37. package/src/utils/path.ts +29 -0
  38. package/templates/default/handoff.config.js.tpl +16 -0
  39. package/templates/default/handoff.config.with-examples.js.tpl +16 -0
@@ -2,6 +2,7 @@ import fs from 'fs-extra';
2
2
  import path from 'path';
3
3
  import Handoff from '../index';
4
4
  import { Logger } from '../utils/logger';
5
+ import { normalizePathForCompare } from '../utils/path';
5
6
  import { computeDirectoryState, computeFileState, directoryStatesMatch, FileState, statesMatch } from './file-state';
6
7
 
7
8
  /** Current cache format version - bump when structure changes */
@@ -162,25 +163,18 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): {
162
163
 
163
164
  const files: string[] = [];
164
165
  let templateDir: string | undefined;
166
+ const componentDirs = new Set<string>();
165
167
 
166
- // Find the config file path for this component
167
- const configPaths = handoff.getConfigFilePaths();
168
- for (const configPath of configPaths) {
169
- // Check if this config path belongs to this component
170
- if (configPath.includes(componentId)) {
171
- files.push(configPath);
172
- break;
173
- }
174
- }
175
-
176
- // Add entry files
168
+ // Add entry files and infer component directories from resolved entry paths
177
169
  const entries = runtimeComponent.entries as Record<string, string | undefined> | undefined;
178
170
  if (entries) {
179
171
  if (entries.js) {
180
172
  files.push(entries.js);
173
+ componentDirs.add(normalizePathForCompare(path.dirname(entries.js)));
181
174
  }
182
175
  if (entries.scss) {
183
176
  files.push(entries.scss);
177
+ componentDirs.add(normalizePathForCompare(path.dirname(entries.scss)));
184
178
  }
185
179
  // Handle both 'template' (singular) and 'templates' (plural) entry types
186
180
  const templatePath = entries.template || entries.templates;
@@ -189,16 +183,40 @@ export function getComponentFilePaths(handoff: Handoff, componentId: string): {
189
183
  const stat = fs.statSync(templatePath);
190
184
  if (stat.isDirectory()) {
191
185
  templateDir = templatePath;
186
+ componentDirs.add(normalizePathForCompare(templatePath));
192
187
  } else {
193
188
  files.push(templatePath);
189
+ componentDirs.add(normalizePathForCompare(path.dirname(templatePath)));
194
190
  }
195
191
  } catch {
196
192
  // File doesn't exist, still add to track
197
193
  files.push(templatePath);
194
+ componentDirs.add(normalizePathForCompare(path.dirname(templatePath)));
198
195
  }
199
196
  }
200
197
  }
201
198
 
199
+ // Find the config file path for this component using exact config filename + directory matching.
200
+ const configPaths = handoff.getConfigFilePaths();
201
+ const expectedConfigFileNames = new Set([`${componentId}.json`, `${componentId}.js`, `${componentId}.cjs`]);
202
+ const matchingConfigPath = configPaths.find((configPath) => {
203
+ const configFileName = path.basename(configPath);
204
+ if (!expectedConfigFileNames.has(configFileName)) {
205
+ return false;
206
+ }
207
+
208
+ if (componentDirs.size === 0) {
209
+ return true;
210
+ }
211
+
212
+ const configDir = normalizePathForCompare(path.dirname(configPath));
213
+ return componentDirs.has(configDir);
214
+ });
215
+
216
+ if (matchingConfigPath) {
217
+ files.push(matchingConfigPath);
218
+ }
219
+
202
220
  return { files, templateDir };
203
221
  }
204
222
 
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.1.0';
22
+ return 'Handoff App - 1.1.2';
23
23
  };
24
24
 
25
25
  /**
package/src/index.ts CHANGED
@@ -297,7 +297,7 @@ class Handoff {
297
297
  }
298
298
 
299
299
  export type { ComponentObject as Component } from './transformers/preview/types';
300
- export type { Config } from './types/config';
300
+ export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
301
301
 
302
302
  // Export transformers and types from handoff-core
303
303
  export { Transformers as CoreTransformers, TransformerUtils as CoreTransformerUtils, Types as CoreTypes } from 'handoff-core';
@@ -69,12 +69,14 @@ async function renderHandlebarsTemplate(
69
69
  template: string,
70
70
  componentData: TransformComponentTokensResult,
71
71
  previewData: PreviewRenderData,
72
- injectFieldWrappers: boolean
72
+ injectFieldWrappers: boolean,
73
+ handoff: Handoff
73
74
  ): Promise<string> {
74
75
  // Register Handlebars helpers with current injection state
75
76
  registerHandlebarsHelpers(
76
- { id: componentData.id, properties: componentData.properties || {} },
77
- injectFieldWrappers
77
+ { id: componentData.id, properties: componentData.properties || {} },
78
+ injectFieldWrappers,
79
+ handoff.config?.hooks?.registerHandlebarsHelpers
78
80
  );
79
81
 
80
82
  const context = createHandlebarsContext({
@@ -158,6 +160,17 @@ export function handlebarsPreviewsPlugin(
158
160
  // Process component instances from documentation
159
161
  processComponentInstances(componentData, documentationComponents);
160
162
 
163
+ if (!componentData.previews || Object.keys(componentData.previews).length === 0) {
164
+ Logger.warn(`No previews defined for ${componentId}; using default preview values.`);
165
+ componentData.previews = {
166
+ default: {
167
+ title: 'Default',
168
+ values: {},
169
+ url: '',
170
+ },
171
+ };
172
+ }
173
+
161
174
  const generatedPreviews: { [key: string]: string } = {};
162
175
 
163
176
  // Generate previews for each variation
@@ -170,14 +183,16 @@ export function handlebarsPreviewsPlugin(
170
183
  templateContent,
171
184
  componentData,
172
185
  previewData,
173
- false
186
+ false,
187
+ handoff
174
188
  );
175
189
 
176
190
  const inspectModeHtml = await renderHandlebarsTemplate(
177
191
  templateContent,
178
192
  componentData,
179
193
  previewData,
180
- true
194
+ true,
195
+ handoff
181
196
  );
182
197
 
183
198
  // Emit preview files
@@ -202,7 +217,8 @@ export function handlebarsPreviewsPlugin(
202
217
  componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT;
203
218
  componentData.preview = '';
204
219
  componentData.code = trimPreview(templateContent);
205
- componentData.html = trimPreview(generatedPreviews[Object.keys(generatedPreviews)[0]]);
220
+ const firstGeneratedPreview = Object.values(generatedPreviews)[0];
221
+ componentData.html = trimPreview(firstGeneratedPreview ?? templateContent);
206
222
  },
207
223
  };
208
224
  }
@@ -62,6 +62,19 @@ type ComponentBuildPlan = {
62
62
  validationMode: boolean;
63
63
  };
64
64
 
65
+ const ensureDefaultPreview = (data: TransformComponentTokensResult): void => {
66
+ if (!data) return;
67
+ if (!data.previews || Object.keys(data.previews).length === 0) {
68
+ data.previews = {
69
+ default: {
70
+ title: 'Default',
71
+ values: {},
72
+ url: '',
73
+ },
74
+ };
75
+ }
76
+ };
77
+
65
78
  /**
66
79
  * Returns a normalized build plan describing which component segments need rebuilding.
67
80
  *
@@ -223,7 +236,7 @@ export async function processComponents(
223
236
  // those will override this default (standard "config overrides defaults" behavior).
224
237
  if (!restMetadata.figmaComponentId) {
225
238
  componentDefaults.previews = {
226
- generic: {
239
+ default: {
227
240
  title: 'Default',
228
241
  values: {},
229
242
  url: '',
@@ -276,6 +289,9 @@ export async function processComponents(
276
289
  }
277
290
  }
278
291
 
292
+ // Components should always have at least one preview variation.
293
+ ensureDefaultPreview(data);
294
+
279
295
  // Build JS if needed (new build, validation missing, or explicit segment request).
280
296
  if (buildPlan.js) {
281
297
  data = await buildComponentJs(data, handoff);
@@ -1,16 +1,19 @@
1
1
  import Handlebars from 'handlebars';
2
+ import { RegisterHandlebarsHelpersContext } from '../../types/config';
2
3
  import { Logger } from '../../utils/logger';
3
4
  import { SlotMetadata } from '../preview/component';
4
5
  import { HandlebarsContext } from '../types';
5
6
 
6
7
  /**
7
- * Registers common Handlebars helpers
8
+ * Registers common Handlebars helpers, then runs optional `hooks.registerHandlebarsHelpers`.
8
9
  * @param data - Component data containing properties
9
10
  * @param injectFieldWrappers - Whether to inject field wrappers for inspection
11
+ * @param extend - Optional callback from config to register additional helpers
10
12
  */
11
13
  export const registerHandlebarsHelpers = (
12
14
  data: { properties: { [key: string]: SlotMetadata }; id: string },
13
- injectFieldWrappers: boolean
15
+ injectFieldWrappers: boolean,
16
+ extend?: (context: RegisterHandlebarsHelpersContext) => void
14
17
  ): void => {
15
18
  // Field helper for property binding
16
19
  Handlebars.registerHelper('field', function (field: string, options: any) {
@@ -20,21 +23,21 @@ export const registerHandlebarsHelpers = (
20
23
  // Logger.error(`Missing field declaration for ${data.id}`);
21
24
  return options.fn(this);
22
25
  }
23
-
26
+
24
27
  let parts = field.split('.');
25
28
  let current: any = data.properties;
26
-
29
+
27
30
  for (const part of parts) {
28
31
  if (current?.type === 'object') current = current.properties;
29
32
  else if (current?.type === 'array') current = current.items.properties;
30
33
  current = current?.[part];
31
34
  }
32
-
35
+
33
36
  if (!current) {
34
37
  Logger.error(`Undefined field path for ${data.id}`);
35
38
  return options.fn(this);
36
39
  }
37
-
40
+
38
41
  return new Handlebars.SafeString(
39
42
  `<span class="handoff-field handoff-field-${current?.type || 'unknown'}" data-handoff-field="${field}" data-handoff="${encodeURIComponent(JSON.stringify(current))}">${options.fn(this)}</span>`
40
43
  );
@@ -47,6 +50,13 @@ export const registerHandlebarsHelpers = (
47
50
  Handlebars.registerHelper('eq', function (a: any, b: any) {
48
51
  return a === b;
49
52
  });
53
+
54
+ extend?.({
55
+ handlebars: Handlebars,
56
+ componentId: data.id,
57
+ properties: data.properties,
58
+ injectFieldWrappers,
59
+ });
50
60
  };
51
61
 
52
62
  /**
@@ -61,9 +71,7 @@ export const createHandlebarsContext = (
61
71
  options?: { includeSharedStyles?: boolean }
62
72
  ): HandlebarsContext => {
63
73
  const basePath = process.env.HANDOFF_APP_BASE_PATH ?? '';
64
- const sharedStylesLink = options?.includeSharedStyles
65
- ? `<link rel="stylesheet" href="${basePath}/api/component/shared.css">`
66
- : '';
74
+ const sharedStylesLink = options?.includeSharedStyles ? `<link rel="stylesheet" href="${basePath}/api/component/shared.css">` : '';
67
75
 
68
76
  return {
69
77
  style:
@@ -7,9 +7,22 @@ import prettier from 'prettier';
7
7
  * @returns Trimmed HTML content
8
8
  */
9
9
  export const trimPreview = (preview: string): string => {
10
- const bodyEl = parse(preview).querySelector('body');
11
- const code = bodyEl ? bodyEl.innerHTML.trim() : preview;
12
- return code;
10
+ if (typeof preview !== 'string') {
11
+ return '';
12
+ }
13
+
14
+ const normalizedPreview = preview.trim();
15
+ if (!normalizedPreview) {
16
+ return '';
17
+ }
18
+
19
+ try {
20
+ const bodyEl = parse(normalizedPreview).querySelector('body');
21
+ return bodyEl ? bodyEl.innerHTML.trim() : normalizedPreview;
22
+ } catch {
23
+ // Keep build resilient if parser fails on malformed HTML.
24
+ return normalizedPreview;
25
+ }
13
26
  };
14
27
 
15
28
  /**
@@ -1,4 +1,5 @@
1
1
  import { BuildOptions } from 'esbuild';
2
+ import type Handlebars from 'handlebars';
2
3
  import { Types as HandoffTypes } from 'handoff-core';
3
4
  import { InlineConfig } from 'vite';
4
5
  import { SlotMetadata } from '../transformers/preview/component';
@@ -47,6 +48,14 @@ export interface Breakpoints {
47
48
  desktop: { size: number; name: string };
48
49
  }
49
50
 
51
+ /** Context passed to the `registerHandlebarsHelpers` config hook after built-in helpers (`field`, `eq`) are registered for a preview render. */
52
+ export type RegisterHandlebarsHelpersContext = {
53
+ handlebars: typeof Handlebars;
54
+ componentId: string;
55
+ properties: { [key: string]: SlotMetadata };
56
+ injectFieldWrappers: boolean;
57
+ };
58
+
50
59
  export interface NextAppConfig {
51
60
  theme?: string;
52
61
  title: string;
@@ -240,6 +249,22 @@ export interface Config {
240
249
  * ```
241
250
  */
242
251
  htmlBuildConfig?: (config: InlineConfig) => InlineConfig;
252
+
253
+ /**
254
+ * Optional hook invoked after Handoff registers built-in Handlebars helpers for
255
+ * component preview HTML. Use `context.handlebars.registerHelper` to add or
256
+ * replace helpers. Called once per preview render (per variation and inspect mode).
257
+ *
258
+ * @param context - Handlebars runtime, component id/properties, and whether
259
+ * inspect field wrappers are enabled for this render.
260
+ * @example
261
+ * ```typescript
262
+ * registerHandlebarsHelpers: ({ handlebars, componentId }) => {
263
+ * handlebars.registerHelper('upperId', () => componentId.toUpperCase());
264
+ * }
265
+ * ```
266
+ */
267
+ registerHandlebarsHelpers?: (context: RegisterHandlebarsHelpersContext) => void;
243
268
  };
244
269
  }
245
270
 
package/src/utils/path.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import path from 'path';
2
+
1
3
  /**
2
4
  * Generates a filesystem-safe directory name from an absolute path
3
5
  * @param workingPath - The absolute working path to transform
@@ -30,3 +32,30 @@ export function generateFilesystemSafeId(workingPath: string): string {
30
32
  return safeId;
31
33
  }
32
34
 
35
+ /**
36
+ * Normalize a filesystem path for cross-platform string comparison.
37
+ * - Resolves to absolute path
38
+ * - Normalizes separators to forward slashes
39
+ * - Lowercases on Windows where paths are case-insensitive
40
+ */
41
+ export function normalizePathForCompare(inputPath: string): string {
42
+ const normalized = path.resolve(inputPath).replace(/\\/g, '/');
43
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
44
+ }
45
+
46
+ /**
47
+ * True when both paths resolve to the same normalized filesystem path.
48
+ */
49
+ export function arePathsEqual(pathA: string, pathB: string): boolean {
50
+ return normalizePathForCompare(pathA) === normalizePathForCompare(pathB);
51
+ }
52
+
53
+ /**
54
+ * True when childPath is equal to or within parentPath.
55
+ */
56
+ export function isPathInside(childPath: string, parentPath: string): boolean {
57
+ const normalizedChild = normalizePathForCompare(childPath);
58
+ const normalizedParent = normalizePathForCompare(parentPath);
59
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}/`);
60
+ }
61
+
@@ -183,5 +183,21 @@ module.exports = {
183
183
  // // Add your custom HTML build configuration here
184
184
  // return config;
185
185
  // },
186
+
187
+ /**
188
+ * Optional hook run after built-in Handlebars helpers (`field`, `eq`) for preview HTML.
189
+ * @param context.handlebars - Handlebars module (use registerHelper)
190
+ * @param context.componentId - Current component id
191
+ * @param context.properties - Slot metadata map from the schema
192
+ * @param context.injectFieldWrappers - True when generating inspect-mode previews
193
+ *
194
+ * @example
195
+ * registerHandlebarsHelpers: ({ handlebars, componentId }) => {
196
+ * handlebars.registerHelper('upperId', () => componentId.toUpperCase());
197
+ * }
198
+ */
199
+ // registerHandlebarsHelpers: ({ handlebars, componentId, properties, injectFieldWrappers }) => {
200
+ // // Add your custom Handlebars helpers here
201
+ // },
186
202
  },
187
203
  };
@@ -184,5 +184,21 @@ module.exports = {
184
184
  // // Add your custom HTML build configuration here
185
185
  // return config;
186
186
  // },
187
+
188
+ /**
189
+ * Optional hook run after built-in Handlebars helpers (`field`, `eq`) for preview HTML.
190
+ * @param context.handlebars - Handlebars module (use registerHelper)
191
+ * @param context.componentId - Current component id
192
+ * @param context.properties - Slot metadata map from the schema
193
+ * @param context.injectFieldWrappers - True when generating inspect-mode previews
194
+ *
195
+ * @example
196
+ * registerHandlebarsHelpers: ({ handlebars, componentId }) => {
197
+ * handlebars.registerHelper('upperId', () => componentId.toUpperCase());
198
+ * }
199
+ */
200
+ // registerHandlebarsHelpers: ({ handlebars, componentId, properties, injectFieldWrappers }) => {
201
+ // // Add your custom Handlebars helpers here
202
+ // },
187
203
  },
188
204
  };