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
package/Changelog.md CHANGED
@@ -6,6 +6,61 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to
7
7
  [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
+ ## \[1.1.2] - 2026-03-26
10
+
11
+ This release improves integration with downstream platforms (for example HubSpot) by letting
12
+ projects register their own Handlebars helpers during preview generation, instead of relying
13
+ only on Handoff’s built-in `field` and `eq` helpers.
14
+
15
+ ### Feature
16
+
17
+ **`hooks.registerHandlebarsHelpers`** — New optional hook in `handoff.config.js`. After Handoff
18
+ registers its default preview helpers, your callback runs with the Handlebars runtime, the
19
+ current `componentId`, the component `properties` (slot metadata), and a flag for inspect-mode
20
+ renders (`injectFieldWrappers`). Use `handlebars.registerHelper` (and block helpers as needed)
21
+ to add helpers that are only available while building component preview HTML—so templates can
22
+ stay platform-specific without forking Handoff.
23
+
24
+ Typical uses: block helpers that wrap regions meant for a host page’s header or footer, or a
25
+ helper that serializes a value as JSON for inline scripts. Those behaviors are **not** shipped
26
+ as core helpers; you define them in config so each project controls naming and semantics.
27
+
28
+ See `docs/api.md` (Hooks → `registerHandlebarsHelpers`) for the full signature and README
29
+ “Configuration hooks” for a quick pointer.
30
+
31
+ ### Example: JSON in templates
32
+
33
+ If you register a `json` helper in `hooks.registerHandlebarsHelpers`, like this
34
+
35
+ ```
36
+ hooks: {
37
+ registerHandlebarsHelpers: ({ handlebars, componentId }) => {
38
+ // Register 'json' as a simple (non-block) helper
39
+ handlebars.registerHelper('json', (value, data) => {
40
+ return new handlebars.SafeString(JSON.stringify(value, null, 2));
41
+ });
42
+ }
43
+ }
44
+ ```
45
+
46
+ Then your templates can do:
47
+
48
+ ```
49
+ <script type="text/javascript">
50
+ window.barChartData = {{{json properties.data}}};
51
+ </script>
52
+ ```
53
+
54
+ with output shaped by your helper implementation (for example serialized JSON for chart data).
55
+
56
+ ## \[1.1.1] - 2026-03-12
57
+
58
+ This patch release focuses on Windows stabilization and consistency across environments.
59
+
60
+ It includes targeted fixes to reduce platform-specific issues in local development and documentation rendering, especially around path handling, routing/navigation behavior, and runtime update detection. These changes aim to make behavior on Windows align more closely with Mac and Linux, with no intended breaking changes.
61
+
62
+ Also includes a fallback for components with empty previews definitions, preventing preview generation failures during builds.
63
+
9
64
  ## \[1.1.0] - 2026-03-05
10
65
 
11
66
  This release introduces two major new features. First, handoff now can do automatic
@@ -1822,3 +1877,6 @@ package version was set to ^0.1.0 rather than ^0.2.0. This release fixes that.
1822
1877
 
1823
1878
  ```
1824
1879
  ```
1880
+
1881
+ ```
1882
+ ```
package/README.md CHANGED
@@ -16,6 +16,7 @@ transform tokens into dev ready artifacts.
16
16
  * [How does it work](#how-does-it-work)
17
17
  * [Handoff is in Beta!](#handoff-is-in-beta)
18
18
  * [Requirements](#requirements)
19
+ * [Configuration hooks](#configuration-hooks)
19
20
  * [Get Started](#get-started)
20
21
  * [Figma](#figma)
21
22
  * [Create a project](#create-a-project)
@@ -168,6 +169,12 @@ handoff-app start
168
169
 
169
170
  Once that runs, your browser should update with the new colors.
170
171
 
172
+ ## Configuration hooks
173
+
174
+ Pipeline customization is done in `handoff.config.js` under `hooks` (camelCase names: `validateComponent`, `jsBuildConfig`, `registerHandlebarsHelpers`, etc.). For example, `registerHandlebarsHelpers` runs after Handoff registers the built-in `field` and `eq` helpers so you can call `handlebars.registerHelper` for your `.hbs` preview templates.
175
+
176
+ See [docs/api.md](docs/api.md#hooks) for hook arguments and examples.
177
+
171
178
  ## Further Reading
172
179
 
173
180
  * [Configure your project](https://www.handoff.com/docs/customization)
@@ -25,6 +25,7 @@ const client_config_1 = require("./client-config");
25
25
  const paths_1 = require("./paths");
26
26
  const watchers_1 = require("./watchers");
27
27
  const websocket_1 = require("./websocket");
28
+ const escapeForSingleQuotedJsString = (value) => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
28
29
  /**
29
30
  * Performs cleanup of the application directory by removing the existing app directory if it exists.
30
31
  */
@@ -57,8 +58,7 @@ const initializeProjectApp = (handoff) => __awaiter(void 0, void 0, void 0, func
57
58
  yield fs_extra_1.default.copy(customThemePath, destPath, { overwrite: true });
58
59
  logger_1.Logger.success(`Custom theme.css loaded`);
59
60
  }
60
- // Prepare project app configuration
61
- // Warning: Regex replacement is fragile and depends on exact formatting in next.config.mjs
61
+ // Prepare project app configuration using stable placeholder replacement.
62
62
  const handoffProjectId = handoff.getProjectId();
63
63
  const handoffAppBasePath = (_a = handoff.config.app.base_path) !== null && _a !== void 0 ? _a : '';
64
64
  const handoffWorkingPath = path_1.default.resolve(handoff.workingPath);
@@ -67,15 +67,24 @@ const initializeProjectApp = (handoff) => __awaiter(void 0, void 0, void 0, func
67
67
  const nextConfigPath = path_1.default.resolve(srcPath, 'next.config.mjs');
68
68
  const targetPath = path_1.default.resolve(appPath, 'next.config.mjs');
69
69
  const handoffWebsocketPort = (_c = (_b = handoff.config.app.ports) === null || _b === void 0 ? void 0 : _b.websocket) !== null && _c !== void 0 ? _c : 3001;
70
- const nextConfigContent = (yield fs_extra_1.default.readFile(nextConfigPath, 'utf-8'))
71
- .replace(/basePath:\s+''/g, `basePath: '${handoffAppBasePath}'`)
72
- .replace(/HANDOFF_PROJECT_ID:\s+''/g, `HANDOFF_PROJECT_ID: '${handoffProjectId}'`)
73
- .replace(/HANDOFF_APP_BASE_PATH:\s+''/g, `HANDOFF_APP_BASE_PATH: '${handoffAppBasePath}'`)
74
- .replace(/HANDOFF_WORKING_PATH:\s+''/g, `HANDOFF_WORKING_PATH: '${handoffWorkingPath}'`)
75
- .replace(/HANDOFF_MODULE_PATH:\s+''/g, `HANDOFF_MODULE_PATH: '${handoffModulePath}'`)
76
- .replace(/HANDOFF_EXPORT_PATH:\s+''/g, `HANDOFF_EXPORT_PATH: '${handoffExportPath}'`)
77
- .replace(/HANDOFF_WEBSOCKET_PORT:\s+''/g, `HANDOFF_WEBSOCKET_PORT: '${handoffWebsocketPort}'`)
78
- .replace(/%HANDOFF_MODULE_PATH%/g, handoffModulePath);
70
+ const escapedAppBasePath = escapeForSingleQuotedJsString(handoffAppBasePath);
71
+ const escapedProjectId = escapeForSingleQuotedJsString(handoffProjectId);
72
+ const escapedWorkingPath = escapeForSingleQuotedJsString(handoffWorkingPath);
73
+ const escapedModulePath = escapeForSingleQuotedJsString(handoffModulePath);
74
+ const escapedExportPath = escapeForSingleQuotedJsString(handoffExportPath);
75
+ const escapedWebsocketPort = escapeForSingleQuotedJsString(String(handoffWebsocketPort));
76
+ const placeholderValues = {
77
+ '%HANDOFF_PROJECT_ID%': escapedProjectId,
78
+ '%HANDOFF_APP_BASE_PATH%': escapedAppBasePath,
79
+ '%HANDOFF_WORKING_PATH%': escapedWorkingPath,
80
+ '%HANDOFF_MODULE_PATH%': escapedModulePath,
81
+ '%HANDOFF_EXPORT_PATH%': escapedExportPath,
82
+ '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
83
+ };
84
+ let nextConfigContent = yield fs_extra_1.default.readFile(nextConfigPath, 'utf-8');
85
+ for (const [placeholder, value] of Object.entries(placeholderValues)) {
86
+ nextConfigContent = nextConfigContent.split(placeholder).join(value);
87
+ }
79
88
  yield fs_extra_1.default.writeFile(targetPath, nextConfigContent);
80
89
  return appPath;
81
90
  });
@@ -43,6 +43,7 @@ const css_1 = require("../transformers/preview/component/css");
43
43
  const javascript_1 = require("../transformers/preview/component/javascript");
44
44
  const builder_1 = __importStar(require("../transformers/preview/component/builder"));
45
45
  const logger_1 = require("../utils/logger");
46
+ const path_2 = require("../utils/path");
46
47
  const client_config_1 = require("./client-config");
47
48
  const paths_1 = require("./paths");
48
49
  /**
@@ -145,11 +146,6 @@ const watchGlobalEntries = (handoff, state, chokidarConfig) => {
145
146
  const pathsToWatch = [...scssPathsToWatch, ...jsPathsToWatch];
146
147
  if (pathsToWatch.length === 0)
147
148
  return;
148
- const isPathInside = (filePath, parentPath) => {
149
- const normalizedFilePath = path_1.default.resolve(filePath);
150
- const normalizedParentPath = path_1.default.resolve(parentPath);
151
- return normalizedFilePath === normalizedParentPath || normalizedFilePath.startsWith(`${normalizedParentPath}${path_1.default.sep}`);
152
- };
153
149
  chokidar_1.default.watch(pathsToWatch, chokidarConfig).on('all', (event, file) => __awaiter(void 0, void 0, void 0, function* () {
154
150
  switch (event) {
155
151
  case 'add':
@@ -159,8 +155,8 @@ const watchGlobalEntries = (handoff, state, chokidarConfig) => {
159
155
  state.debounce = true;
160
156
  try {
161
157
  logger_1.Logger.warn('Global entry changed. Rebuilding bundles and components...');
162
- const shouldRebuildMainCss = !!scssEntry && scssPathsToWatch.some((watchPath) => isPathInside(file, watchPath));
163
- const shouldRebuildMainJs = !!jsEntry && jsPathsToWatch.some((watchPath) => isPathInside(file, watchPath));
158
+ const shouldRebuildMainCss = !!scssEntry && scssPathsToWatch.some((watchPath) => (0, path_2.isPathInside)(file, watchPath));
159
+ const shouldRebuildMainJs = !!jsEntry && jsPathsToWatch.some((watchPath) => (0, path_2.isPathInside)(file, watchPath));
164
160
  if (shouldRebuildMainCss) {
165
161
  yield (0, css_1.buildMainCss)(handoff);
166
162
  }
@@ -228,11 +224,14 @@ const watchRuntimeComponents = (handoff, state, runtimeComponentPathsToWatch) =>
228
224
  }
229
225
  if (runtimeComponentPathsToWatch.size > 0) {
230
226
  const pathsToWatch = Array.from(runtimeComponentPathsToWatch.keys());
227
+ const runtimeComponentEntryTypeByPath = new Map(Array.from(runtimeComponentPathsToWatch.entries()).map(([watchPath, entryType]) => [(0, path_2.normalizePathForCompare)(watchPath), entryType]));
228
+ const configFileSet = new Set(handoff.getConfigFilePaths().map((configPath) => (0, path_2.normalizePathForCompare)(configPath)));
231
229
  state.runtimeComponentsWatcher = chokidar_1.default.watch(pathsToWatch, {
232
230
  ignoreInitial: true,
233
231
  });
234
232
  state.runtimeComponentsWatcher.on('all', (event, file) => __awaiter(void 0, void 0, void 0, function* () {
235
- if (handoff.getConfigFilePaths().includes(file)) {
233
+ const normalizedFile = (0, path_2.normalizePathForCompare)(file);
234
+ if (configFileSet.has(normalizedFile)) {
236
235
  return;
237
236
  }
238
237
  switch (event) {
@@ -242,7 +241,7 @@ const watchRuntimeComponents = (handoff, state, runtimeComponentPathsToWatch) =>
242
241
  if (!state.debounce) {
243
242
  state.debounce = true;
244
243
  try {
245
- const entryType = runtimeComponentPathsToWatch.get(file);
244
+ const entryType = runtimeComponentEntryTypeByPath.get(normalizedFile);
246
245
  const segmentToUpdate = entryType ? (0, exports.mapEntryTypeToSegment)(entryType) : undefined;
247
246
  const componentDir = path_1.default.basename(path_1.default.dirname(file));
248
247
  yield (0, builder_1.default)(handoff, componentDir, segmentToUpdate);
@@ -27,6 +27,7 @@ exports.pruneRemovedComponents = pruneRemovedComponents;
27
27
  const fs_extra_1 = __importDefault(require("fs-extra"));
28
28
  const path_1 = __importDefault(require("path"));
29
29
  const logger_1 = require("../utils/logger");
30
+ const path_2 = require("../utils/path");
30
31
  const file_state_1 = require("./file-state");
31
32
  /** Current cache format version - bump when structure changes */
32
33
  const CACHE_VERSION = '1.0.0';
@@ -142,23 +143,17 @@ function getComponentFilePaths(handoff, componentId) {
142
143
  }
143
144
  const files = [];
144
145
  let templateDir;
145
- // Find the config file path for this component
146
- const configPaths = handoff.getConfigFilePaths();
147
- for (const configPath of configPaths) {
148
- // Check if this config path belongs to this component
149
- if (configPath.includes(componentId)) {
150
- files.push(configPath);
151
- break;
152
- }
153
- }
154
- // Add entry files
146
+ const componentDirs = new Set();
147
+ // Add entry files and infer component directories from resolved entry paths
155
148
  const entries = runtimeComponent.entries;
156
149
  if (entries) {
157
150
  if (entries.js) {
158
151
  files.push(entries.js);
152
+ componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(entries.js)));
159
153
  }
160
154
  if (entries.scss) {
161
155
  files.push(entries.scss);
156
+ componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(entries.scss)));
162
157
  }
163
158
  // Handle both 'template' (singular) and 'templates' (plural) entry types
164
159
  const templatePath = entries.template || entries.templates;
@@ -167,17 +162,37 @@ function getComponentFilePaths(handoff, componentId) {
167
162
  const stat = fs_extra_1.default.statSync(templatePath);
168
163
  if (stat.isDirectory()) {
169
164
  templateDir = templatePath;
165
+ componentDirs.add((0, path_2.normalizePathForCompare)(templatePath));
170
166
  }
171
167
  else {
172
168
  files.push(templatePath);
169
+ componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(templatePath)));
173
170
  }
174
171
  }
175
172
  catch (_d) {
176
173
  // File doesn't exist, still add to track
177
174
  files.push(templatePath);
175
+ componentDirs.add((0, path_2.normalizePathForCompare)(path_1.default.dirname(templatePath)));
178
176
  }
179
177
  }
180
178
  }
179
+ // Find the config file path for this component using exact config filename + directory matching.
180
+ const configPaths = handoff.getConfigFilePaths();
181
+ const expectedConfigFileNames = new Set([`${componentId}.json`, `${componentId}.js`, `${componentId}.cjs`]);
182
+ const matchingConfigPath = configPaths.find((configPath) => {
183
+ const configFileName = path_1.default.basename(configPath);
184
+ if (!expectedConfigFileNames.has(configFileName)) {
185
+ return false;
186
+ }
187
+ if (componentDirs.size === 0) {
188
+ return true;
189
+ }
190
+ const configDir = (0, path_2.normalizePathForCompare)(path_1.default.dirname(configPath));
191
+ return componentDirs.has(configDir);
192
+ });
193
+ if (matchingConfigPath) {
194
+ files.push(matchingConfigPath);
195
+ }
181
196
  return { files, templateDir };
182
197
  }
183
198
  /**
package/dist/cli.js 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
  * Define a CLI error
package/dist/index.d.ts CHANGED
@@ -95,6 +95,6 @@ declare class Handoff {
95
95
  private readJsonFile;
96
96
  }
97
97
  export type { ComponentObject as Component } from './transformers/preview/types';
98
- export type { Config } from './types/config';
98
+ export type { Config, RegisterHandlebarsHelpersContext } from './types/config';
99
99
  export { Transformers as CoreTransformers, TransformerUtils as CoreTransformerUtils, Types as CoreTypes } from 'handoff-core';
100
100
  export default Handoff;
@@ -61,10 +61,11 @@ function processComponentInstances(componentData, documentationComponents) {
61
61
  * @param injectFieldWrappers - Whether to inject field wrappers for inspection
62
62
  * @returns Rendered HTML string
63
63
  */
64
- function renderHandlebarsTemplate(template, componentData, previewData, injectFieldWrappers) {
64
+ function renderHandlebarsTemplate(template, componentData, previewData, injectFieldWrappers, handoff) {
65
65
  return __awaiter(this, void 0, void 0, function* () {
66
+ var _a, _b;
66
67
  // Register Handlebars helpers with current injection state
67
- (0, handlebars_2.registerHandlebarsHelpers)({ id: componentData.id, properties: componentData.properties || {} }, injectFieldWrappers);
68
+ (0, handlebars_2.registerHandlebarsHelpers)({ id: componentData.id, properties: componentData.properties || {} }, injectFieldWrappers, (_b = (_a = handoff.config) === null || _a === void 0 ? void 0 : _a.hooks) === null || _b === void 0 ? void 0 : _b.registerHandlebarsHelpers);
68
69
  const context = (0, handlebars_2.createHandlebarsContext)({
69
70
  id: componentData.id,
70
71
  properties: componentData.properties || {},
@@ -131,14 +132,24 @@ function handlebarsPreviewsPlugin(componentData, documentationComponents, handof
131
132
  }
132
133
  // Process component instances from documentation
133
134
  processComponentInstances(componentData, documentationComponents);
135
+ if (!componentData.previews || Object.keys(componentData.previews).length === 0) {
136
+ logger_1.Logger.warn(`No previews defined for ${componentId}; using default preview values.`);
137
+ componentData.previews = {
138
+ default: {
139
+ title: 'Default',
140
+ values: {},
141
+ url: '',
142
+ },
143
+ };
144
+ }
134
145
  const generatedPreviews = {};
135
146
  // Generate previews for each variation
136
147
  for (const previewKey in componentData.previews) {
137
148
  try {
138
149
  const previewData = componentData.previews[previewKey];
139
150
  // Render both normal and inspect modes
140
- const normalModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, false);
141
- const inspectModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, true);
151
+ const normalModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, false, handoff);
152
+ const inspectModeHtml = yield renderHandlebarsTemplate(templateContent, componentData, previewData, true, handoff);
142
153
  // Emit preview files
143
154
  emitPreviewFiles(componentId, previewKey, normalModeHtml, inspectModeHtml, (file) => this.emitFile(file));
144
155
  generatedPreviews[previewKey] = normalModeHtml;
@@ -153,7 +164,8 @@ function handlebarsPreviewsPlugin(componentData, documentationComponents, handof
153
164
  componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT;
154
165
  componentData.preview = '';
155
166
  componentData.code = (0, html_1.trimPreview)(templateContent);
156
- componentData.html = (0, html_1.trimPreview)(generatedPreviews[Object.keys(generatedPreviews)[0]]);
167
+ const firstGeneratedPreview = Object.values(generatedPreviews)[0];
168
+ componentData.html = (0, html_1.trimPreview)(firstGeneratedPreview !== null && firstGeneratedPreview !== void 0 ? firstGeneratedPreview : templateContent);
157
169
  });
158
170
  },
159
171
  };
@@ -66,6 +66,19 @@ var ComponentSegment;
66
66
  ComponentSegment["Previews"] = "previews";
67
67
  ComponentSegment["Validation"] = "validation";
68
68
  })(ComponentSegment || (exports.ComponentSegment = ComponentSegment = {}));
69
+ const ensureDefaultPreview = (data) => {
70
+ if (!data)
71
+ return;
72
+ if (!data.previews || Object.keys(data.previews).length === 0) {
73
+ data.previews = {
74
+ default: {
75
+ title: 'Default',
76
+ values: {},
77
+ url: '',
78
+ },
79
+ };
80
+ }
81
+ };
69
82
  /**
70
83
  * Returns a normalized build plan describing which component segments need rebuilding.
71
84
  *
@@ -200,7 +213,7 @@ function processComponents(handoff, id, segmentToProcess, options) {
200
213
  // those will override this default (standard "config overrides defaults" behavior).
201
214
  if (!restMetadata.figmaComponentId) {
202
215
  componentDefaults.previews = {
203
- generic: {
216
+ default: {
204
217
  title: 'Default',
205
218
  values: {},
206
219
  url: '',
@@ -246,6 +259,8 @@ function processComponents(handoff, id, segmentToProcess, options) {
246
259
  data.properties = existingData.properties;
247
260
  }
248
261
  }
262
+ // Components should always have at least one preview variation.
263
+ ensureDefaultPreview(data);
249
264
  // Build JS if needed (new build, validation missing, or explicit segment request).
250
265
  if (buildPlan.js) {
251
266
  data = yield (0, javascript_1.default)(data, handoff);
@@ -1,16 +1,18 @@
1
+ import { RegisterHandlebarsHelpersContext } from '../../types/config';
1
2
  import { SlotMetadata } from '../preview/component';
2
3
  import { HandlebarsContext } from '../types';
3
4
  /**
4
- * Registers common Handlebars helpers
5
+ * Registers common Handlebars helpers, then runs optional `hooks.registerHandlebarsHelpers`.
5
6
  * @param data - Component data containing properties
6
7
  * @param injectFieldWrappers - Whether to inject field wrappers for inspection
8
+ * @param extend - Optional callback from config to register additional helpers
7
9
  */
8
10
  export declare const registerHandlebarsHelpers: (data: {
9
11
  properties: {
10
12
  [key: string]: SlotMetadata;
11
13
  };
12
14
  id: string;
13
- }, injectFieldWrappers: boolean) => void;
15
+ }, injectFieldWrappers: boolean, extend?: (context: RegisterHandlebarsHelpersContext) => void) => void;
14
16
  /**
15
17
  * Creates Handlebars template context
16
18
  * @param data - Component data
@@ -7,11 +7,12 @@ exports.createHandlebarsContext = exports.registerHandlebarsHelpers = void 0;
7
7
  const handlebars_1 = __importDefault(require("handlebars"));
8
8
  const logger_1 = require("../../utils/logger");
9
9
  /**
10
- * Registers common Handlebars helpers
10
+ * Registers common Handlebars helpers, then runs optional `hooks.registerHandlebarsHelpers`.
11
11
  * @param data - Component data containing properties
12
12
  * @param injectFieldWrappers - Whether to inject field wrappers for inspection
13
+ * @param extend - Optional callback from config to register additional helpers
13
14
  */
14
- const registerHandlebarsHelpers = (data, injectFieldWrappers) => {
15
+ const registerHandlebarsHelpers = (data, injectFieldWrappers, extend) => {
15
16
  // Field helper for property binding
16
17
  handlebars_1.default.registerHelper('field', function (field, options) {
17
18
  if (injectFieldWrappers) {
@@ -43,6 +44,12 @@ const registerHandlebarsHelpers = (data, injectFieldWrappers) => {
43
44
  handlebars_1.default.registerHelper('eq', function (a, b) {
44
45
  return a === b;
45
46
  });
47
+ extend === null || extend === void 0 ? void 0 : extend({
48
+ handlebars: handlebars_1.default,
49
+ componentId: data.id,
50
+ properties: data.properties,
51
+ injectFieldWrappers,
52
+ });
46
53
  };
47
54
  exports.registerHandlebarsHelpers = registerHandlebarsHelpers;
48
55
  /**
@@ -54,9 +61,7 @@ exports.registerHandlebarsHelpers = registerHandlebarsHelpers;
54
61
  const createHandlebarsContext = (data, previewData, options) => {
55
62
  var _a;
56
63
  const basePath = (_a = process.env.HANDOFF_APP_BASE_PATH) !== null && _a !== void 0 ? _a : '';
57
- const sharedStylesLink = (options === null || options === void 0 ? void 0 : options.includeSharedStyles)
58
- ? `<link rel="stylesheet" href="${basePath}/api/component/shared.css">`
59
- : '';
64
+ const sharedStylesLink = (options === null || options === void 0 ? void 0 : options.includeSharedStyles) ? `<link rel="stylesheet" href="${basePath}/api/component/shared.css">` : '';
60
65
  return {
61
66
  style: `${sharedStylesLink}<link rel="stylesheet" href="${basePath}/api/component/main.css">` +
62
67
  `<link rel="stylesheet" href="${basePath}/api/component/${data.id}.css">\n` +
@@ -21,9 +21,21 @@ const prettier_1 = __importDefault(require("prettier"));
21
21
  * @returns Trimmed HTML content
22
22
  */
23
23
  const trimPreview = (preview) => {
24
- const bodyEl = (0, node_html_parser_1.parse)(preview).querySelector('body');
25
- const code = bodyEl ? bodyEl.innerHTML.trim() : preview;
26
- return code;
24
+ if (typeof preview !== 'string') {
25
+ return '';
26
+ }
27
+ const normalizedPreview = preview.trim();
28
+ if (!normalizedPreview) {
29
+ return '';
30
+ }
31
+ try {
32
+ const bodyEl = (0, node_html_parser_1.parse)(normalizedPreview).querySelector('body');
33
+ return bodyEl ? bodyEl.innerHTML.trim() : normalizedPreview;
34
+ }
35
+ catch (_a) {
36
+ // Keep build resilient if parser fails on malformed HTML.
37
+ return normalizedPreview;
38
+ }
27
39
  };
28
40
  exports.trimPreview = trimPreview;
29
41
  /**
@@ -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';
@@ -51,6 +52,15 @@ export interface Breakpoints {
51
52
  name: string;
52
53
  };
53
54
  }
55
+ /** Context passed to the `registerHandlebarsHelpers` config hook after built-in helpers (`field`, `eq`) are registered for a preview render. */
56
+ export type RegisterHandlebarsHelpersContext = {
57
+ handlebars: typeof Handlebars;
58
+ componentId: string;
59
+ properties: {
60
+ [key: string]: SlotMetadata;
61
+ };
62
+ injectFieldWrappers: boolean;
63
+ };
54
64
  export interface NextAppConfig {
55
65
  theme?: string;
56
66
  title: string;
@@ -238,6 +248,21 @@ export interface Config {
238
248
  * ```
239
249
  */
240
250
  htmlBuildConfig?: (config: InlineConfig) => InlineConfig;
251
+ /**
252
+ * Optional hook invoked after Handoff registers built-in Handlebars helpers for
253
+ * component preview HTML. Use `context.handlebars.registerHelper` to add or
254
+ * replace helpers. Called once per preview render (per variation and inspect mode).
255
+ *
256
+ * @param context - Handlebars runtime, component id/properties, and whether
257
+ * inspect field wrappers are enabled for this render.
258
+ * @example
259
+ * ```typescript
260
+ * registerHandlebarsHelpers: ({ handlebars, componentId }) => {
261
+ * handlebars.registerHelper('upperId', () => componentId.toUpperCase());
262
+ * }
263
+ * ```
264
+ */
265
+ registerHandlebarsHelpers?: (context: RegisterHandlebarsHelpersContext) => void;
241
266
  };
242
267
  }
243
268
  export type ClientConfig = Pick<Config, 'app' | 'exportsOutputDirectory' | 'sitesOutputDirectory' | 'assets_zip_links' | 'useVariables'>;
@@ -4,3 +4,18 @@
4
4
  * @returns A filesystem-safe string suitable for use as a directory name
5
5
  */
6
6
  export declare function generateFilesystemSafeId(workingPath: string): string;
7
+ /**
8
+ * Normalize a filesystem path for cross-platform string comparison.
9
+ * - Resolves to absolute path
10
+ * - Normalizes separators to forward slashes
11
+ * - Lowercases on Windows where paths are case-insensitive
12
+ */
13
+ export declare function normalizePathForCompare(inputPath: string): string;
14
+ /**
15
+ * True when both paths resolve to the same normalized filesystem path.
16
+ */
17
+ export declare function arePathsEqual(pathA: string, pathB: string): boolean;
18
+ /**
19
+ * True when childPath is equal to or within parentPath.
20
+ */
21
+ export declare function isPathInside(childPath: string, parentPath: string): boolean;
@@ -1,6 +1,13 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.generateFilesystemSafeId = generateFilesystemSafeId;
7
+ exports.normalizePathForCompare = normalizePathForCompare;
8
+ exports.arePathsEqual = arePathsEqual;
9
+ exports.isPathInside = isPathInside;
10
+ const path_1 = __importDefault(require("path"));
4
11
  /**
5
12
  * Generates a filesystem-safe directory name from an absolute path
6
13
  * @param workingPath - The absolute working path to transform
@@ -26,3 +33,27 @@ function generateFilesystemSafeId(workingPath) {
26
33
  }
27
34
  return safeId;
28
35
  }
36
+ /**
37
+ * Normalize a filesystem path for cross-platform string comparison.
38
+ * - Resolves to absolute path
39
+ * - Normalizes separators to forward slashes
40
+ * - Lowercases on Windows where paths are case-insensitive
41
+ */
42
+ function normalizePathForCompare(inputPath) {
43
+ const normalized = path_1.default.resolve(inputPath).replace(/\\/g, '/');
44
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
45
+ }
46
+ /**
47
+ * True when both paths resolve to the same normalized filesystem path.
48
+ */
49
+ function arePathsEqual(pathA, pathB) {
50
+ return normalizePathForCompare(pathA) === normalizePathForCompare(pathB);
51
+ }
52
+ /**
53
+ * True when childPath is equal to or within parentPath.
54
+ */
55
+ function isPathInside(childPath, parentPath) {
56
+ const normalizedChild = normalizePathForCompare(childPath);
57
+ const normalizedParent = normalizePathForCompare(parentPath);
58
+ return normalizedChild === normalizedParent || normalizedChild.startsWith(`${normalizedParent}/`);
59
+ }