handoff-app 1.1.0 → 1.1.1

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.
package/Changelog.md CHANGED
@@ -6,6 +6,14 @@ 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.1] - 2026-03-12
10
+
11
+ This patch release focuses on Windows stabilization and consistency across environments.
12
+
13
+ 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.
14
+
15
+ Also includes a fallback for components with empty previews definitions, preventing preview generation failures during builds.
16
+
9
17
  ## \[1.1.0] - 2026-03-05
10
18
 
11
19
  This release introduces two major new features. First, handoff now can do automatic
@@ -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.1';
23
23
  };
24
24
  /**
25
25
  * Define a CLI error
@@ -131,6 +131,16 @@ function handlebarsPreviewsPlugin(componentData, documentationComponents, handof
131
131
  }
132
132
  // Process component instances from documentation
133
133
  processComponentInstances(componentData, documentationComponents);
134
+ if (!componentData.previews || Object.keys(componentData.previews).length === 0) {
135
+ logger_1.Logger.warn(`No previews defined for ${componentId}; using default preview values.`);
136
+ componentData.previews = {
137
+ default: {
138
+ title: 'Default',
139
+ values: {},
140
+ url: '',
141
+ },
142
+ };
143
+ }
134
144
  const generatedPreviews = {};
135
145
  // Generate previews for each variation
136
146
  for (const previewKey in componentData.previews) {
@@ -153,7 +163,8 @@ function handlebarsPreviewsPlugin(componentData, documentationComponents, handof
153
163
  componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT;
154
164
  componentData.preview = '';
155
165
  componentData.code = (0, html_1.trimPreview)(templateContent);
156
- componentData.html = (0, html_1.trimPreview)(generatedPreviews[Object.keys(generatedPreviews)[0]]);
166
+ const firstGeneratedPreview = Object.values(generatedPreviews)[0];
167
+ componentData.html = (0, html_1.trimPreview)(firstGeneratedPreview !== null && firstGeneratedPreview !== void 0 ? firstGeneratedPreview : templateContent);
157
168
  });
158
169
  },
159
170
  };
@@ -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);
@@ -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
  /**
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -8,7 +8,7 @@ import HeadersType from '../Typography/Headers';
8
8
  import { Button } from '../ui/button';
9
9
  import { Input } from '../ui/input';
10
10
  import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
11
- import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
11
+ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../ui/tooltip';
12
12
  import { Metadata } from '../util';
13
13
 
14
14
  interface ComponentMetadata extends Metadata {
@@ -74,7 +74,8 @@ export const ComponentList = ({
74
74
  if (!title) title = 'Components';
75
75
  if (!description) description = 'Self-contained reusable UI elements that can be used to build larger blocks or design patterns.';
76
76
  return (
77
- <div className="mx-auto w-full mb-4">
77
+ <TooltipProvider delayDuration={0}>
78
+ <div className="mx-auto w-full mb-4">
78
79
  <div className="flex justify-between bg-accent rounded-xl mb-8">
79
80
  <div className="mr-auto flex items-center gap-3 bg-accent p-2.5 rounded-md">
80
81
  <div className="relative">
@@ -183,8 +184,8 @@ export const ComponentList = ({
183
184
  })}
184
185
  </div>
185
186
  )}
186
- </div>
187
-
187
+ </div>
188
+ </TooltipProvider>
188
189
  );
189
190
  };
190
191
 
@@ -31,13 +31,12 @@ export function MainNav() {
31
31
  <NavigationMenuLink className={navigationMenuTriggerStyle()} asChild>
32
32
  <Link
33
33
  href={section.path}
34
- passHref
35
34
  className={cn(
36
35
  'block select-none space-y-1 rounded-sm p-3 leading-none no-underline outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground'
37
36
  )}
38
37
  {...(isActive ? { 'data-active': 'true' } : {})}
39
38
  >
40
- {section.title} {isActive}
39
+ {section.title}
41
40
  </Link>
42
41
  </NavigationMenuLink>
43
42
  </>
@@ -51,11 +50,10 @@ export function MainNav() {
51
50
  <NavigationMenuLink className={navigationMenuTriggerStyle()} asChild>
52
51
  <Link
53
52
  href={section.path}
54
- passHref
55
- legacyBehavior
56
53
  className="block select-none space-y-1 rounded-sm p-3 leading-none no-underline outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
54
+ {...(isActive ? { 'data-active': 'true' } : {})}
57
55
  >
58
- <div className="text-sm leading-none">{section.title}</div>
56
+ <span className="text-sm leading-none">{section.title}</span>
59
57
  </Link>
60
58
  </NavigationMenuLink>
61
59
  )}
@@ -30,19 +30,16 @@ import {
30
30
  SidebarMenuSub,
31
31
  SidebarSeparator,
32
32
  } from '../../components/ui/sidebar';
33
+ import { normalizePathForMatch, toAbsolutePath } from '../../lib/utils';
33
34
  import { SectionLink } from '../util';
34
35
 
35
- const trimSlashes = (input: string): string => {
36
- return input.replace(/^\/+|\/+$/g, '');
37
- };
38
-
39
36
  const NormalMenuItem = ({ title, icon, path }) => {
40
37
  const router = useRouter();
41
- const isActive = trimSlashes(path) === trimSlashes(router.asPath);
38
+ const isActive = normalizePathForMatch(path) === normalizePathForMatch(router.asPath);
42
39
  return (
43
40
  <SidebarMenuItem>
44
41
  <SidebarMenuButton asChild isActive={isActive}>
45
- <a href={`/${path}`} className="gap-3">
42
+ <a href={toAbsolutePath(path)} className="gap-3">
46
43
  <MenuIcon icon={icon} isActive={isActive} />
47
44
  <span>{title}</span>
48
45
  </a>
@@ -54,7 +51,7 @@ const NormalMenuItem = ({ title, icon, path }) => {
54
51
  const CollapsibleMenuItem = ({ title, icon, path, menu }) => {
55
52
  const router = useRouter();
56
53
  const isActive = menu.some(
57
- (item) => trimSlashes(router.asPath).startsWith(trimSlashes(item.path)) || trimSlashes(item.path) === trimSlashes(router.asPath)
54
+ (item) => normalizePathForMatch(router.asPath).startsWith(normalizePathForMatch(item.path))
58
55
  );
59
56
  return (
60
57
  <Collapsible defaultOpen={isActive} className="group/collapsible">
@@ -1,9 +1,11 @@
1
1
  import { usePathname } from 'next/navigation';
2
+ import { normalizePathForMatch, toAbsolutePath } from '../../lib/utils';
2
3
  import NavLink from '../NavLink';
3
4
  import { SectionLink } from '../util';
4
5
 
5
6
  const CustomNav = ({ menu }: { menu: SectionLink }) => {
6
7
  const pathname = usePathname();
8
+ const normalizedPathname = normalizePathForMatch(pathname ?? '');
7
9
  return (
8
10
  <nav className="c-sidenav">
9
11
  <ul>
@@ -13,14 +15,14 @@ const CustomNav = ({ menu }: { menu: SectionLink }) => {
13
15
  <small>{item.title}</small>
14
16
  ) : (
15
17
  <>
16
- <NavLink href={`/${item.path}`}>
18
+ <NavLink href={toAbsolutePath(item.path)}>
17
19
  {item.title}
18
20
  </NavLink>
19
21
  {item.menu && (
20
- <ul className={`c-sidenav__sub${pathname.includes(item.path) ? ' is-active' : ''}`}>
22
+ <ul className={`c-sidenav__sub${normalizedPathname.startsWith(normalizePathForMatch(item.path)) ? ' is-active' : ''}`}>
21
23
  {item.menu.map((subItem) => (
22
24
  <li key={subItem.path}>
23
- <NavLink key={subItem.path} href={`/${subItem.path}`} className="c-sidenav__sub-item">
25
+ <NavLink key={subItem.path} href={toAbsolutePath(subItem.path)} className="c-sidenav__sub-item">
24
26
  {subItem.title}
25
27
  </NavLink>
26
28
  </li>
@@ -1,8 +1,9 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
- import { usePathname, useRouter } from 'next/navigation';
4
+ import { usePathname } from 'next/navigation';
5
5
  import * as React from 'react';
6
+ import { normalizePathForMatch, toAbsolutePath } from '../../lib/utils';
6
7
  import { SectionLink } from '../util';
7
8
 
8
9
  import { Button } from './button';
@@ -15,7 +16,7 @@ interface MobileNavProps {
15
16
  export function MobileNav({ menu }: MobileNavProps) {
16
17
  const [open, setOpen] = React.useState(false);
17
18
  const pathname = usePathname();
18
- const router = useRouter();
19
+ const normalizedPathname = normalizePathForMatch(pathname ?? '');
19
20
 
20
21
  const onOpenChange = React.useCallback((open: boolean) => {
21
22
  setOpen(open);
@@ -52,24 +53,26 @@ export function MobileNav({ menu }: MobileNavProps) {
52
53
  ) : (
53
54
  <>
54
55
  <Link
55
- href={`/${item.path}`}
56
+ href={toAbsolutePath(item.path)}
56
57
  className="text-[1.15rem]"
57
58
  onClick={() => {
58
- router.push(`/${item.path}`);
59
59
  setOpen(false);
60
60
  }}
61
61
  >
62
62
  {item.title}
63
63
  </Link>
64
64
  {item.menu && (
65
- <ul className={`ml-4 mt-2 space-y-2${pathname.includes(item.path) ? ' is-active' : ''}`}>
65
+ <ul
66
+ className={`ml-4 mt-2 space-y-2${
67
+ normalizedPathname.startsWith(normalizePathForMatch(item.path)) ? ' is-active' : ''
68
+ }`}
69
+ >
66
70
  {item.menu.map((subItem) => (
67
71
  <li key={subItem.path}>
68
72
  <Link
69
- href={`/${subItem.path}`}
73
+ href={toAbsolutePath(subItem.path)}
70
74
  className="text-[1rem] opacity-80"
71
75
  onClick={() => {
72
- router.push(`/${subItem.path}`);
73
76
  setOpen(false);
74
77
  }}
75
78
  >
@@ -290,10 +290,10 @@ export const staticBuildMenu = () => {
290
290
  };
291
291
 
292
292
  const buildBasePath = () => {
293
- if(!process.env.HANDOFF_APP_BASE_PATH) {
293
+ if (!process.env.HANDOFF_APP_BASE_PATH) {
294
294
  return '';
295
295
  }
296
- return (process.env.HANDOFF_APP_BASE_PATH ?? '').replace(/^\//, '') + '/';
296
+ return (process.env.HANDOFF_APP_BASE_PATH ?? '').replace(/^\/+|\/+$/g, '') + '/';
297
297
  };
298
298
 
299
299
  const staticBuildComponentMenu = (type?: boolean | string) => {
@@ -5,6 +5,19 @@ export function cn(...inputs: ClassValue[]) {
5
5
  return twMerge(clsx(inputs));
6
6
  }
7
7
 
8
+ export const trimSlashes = (input: string): string => {
9
+ return input.replace(/^\/+|\/+$/g, '');
10
+ };
11
+
12
+ export const toAbsolutePath = (input: string): string => {
13
+ return `/${trimSlashes(input)}`;
14
+ };
15
+
16
+ export const normalizePathForMatch = (input: string): string => {
17
+ const [pathname] = input.split(/[?#]/);
18
+ return trimSlashes(pathname);
19
+ };
20
+
8
21
  /**
9
22
  * Filters out null values
10
23
  * @param value
@@ -2,6 +2,14 @@ import chalk from 'chalk';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
 
5
+ const resolveBasePath = (rawBasePath) => {
6
+ if (!rawBasePath || rawBasePath.startsWith('%HANDOFF_')) {
7
+ return '';
8
+ }
9
+ const trimmed = rawBasePath.replace(/^\/+|\/+$/g, '');
10
+ return trimmed ? `/${trimmed}` : '';
11
+ };
12
+
5
13
  /** @type {import('next').NextConfig} */
6
14
  const nextConfig = {
7
15
  output: 'export',
@@ -19,14 +27,14 @@ const nextConfig = {
19
27
  tsconfigPath: 'tsconfig.json',
20
28
  },
21
29
  //distDir: 'out',
22
- basePath: '',
30
+ basePath: resolveBasePath('%HANDOFF_APP_BASE_PATH%'),
23
31
  env: {
24
- HANDOFF_PROJECT_ID: '',
25
- HANDOFF_APP_BASE_PATH: '',
26
- HANDOFF_WORKING_PATH: '',
27
- HANDOFF_MODULE_PATH: '',
28
- HANDOFF_EXPORT_PATH: '',
29
- HANDOFF_WEBSOCKET_PORT: '',
32
+ HANDOFF_PROJECT_ID: '%HANDOFF_PROJECT_ID%',
33
+ HANDOFF_APP_BASE_PATH: '%HANDOFF_APP_BASE_PATH%',
34
+ HANDOFF_WORKING_PATH: '%HANDOFF_WORKING_PATH%',
35
+ HANDOFF_MODULE_PATH: '%HANDOFF_MODULE_PATH%',
36
+ HANDOFF_EXPORT_PATH: '%HANDOFF_EXPORT_PATH%',
37
+ HANDOFF_WEBSOCKET_PORT: '%HANDOFF_WEBSOCKET_PORT%',
30
38
  },
31
39
  images: {
32
40
  unoptimized: true,
@@ -38,12 +46,12 @@ const nextConfig = {
38
46
 
39
47
  // Local environment
40
48
  const env = {
41
- HANDOFF_PROJECT_ID: '',
42
- HANDOFF_APP_BASE_PATH: '',
43
- HANDOFF_WORKING_PATH: '',
44
- HANDOFF_MODULE_PATH: '',
45
- HANDOFF_EXPORT_PATH: '',
46
- HANDOFF_WEBSOCKET_PORT: '',
49
+ HANDOFF_PROJECT_ID: '%HANDOFF_PROJECT_ID%',
50
+ HANDOFF_APP_BASE_PATH: '%HANDOFF_APP_BASE_PATH%',
51
+ HANDOFF_WORKING_PATH: '%HANDOFF_WORKING_PATH%',
52
+ HANDOFF_MODULE_PATH: '%HANDOFF_MODULE_PATH%',
53
+ HANDOFF_EXPORT_PATH: '%HANDOFF_EXPORT_PATH%',
54
+ HANDOFF_WEBSOCKET_PORT: '%HANDOFF_WEBSOCKET_PORT%',
47
55
  };
48
56
 
49
57
  // Check if client configuration exists
@@ -163,7 +163,7 @@ const GenericComponentPage = ({ menu, metadata, current, id, config, componentHo
163
163
  }, [component, id]);
164
164
 
165
165
  if (!component) return <p>Loading...</p>;
166
- const apiUrl = (window.location.origin && window.location.origin) + `/api/component/${id}.json`;
166
+ const apiUrl = (window.location.origin && window.location.origin) + `${normalizedBasePath}/api/component/${id}.json`;
167
167
  return (
168
168
  <Layout config={config} menu={menu} current={current} metadata={metadata}>
169
169
  <div className="flex flex-col gap-3 pb-14">
@@ -12,6 +12,8 @@ import { getAppPath, syncPublicFiles } from './paths';
12
12
  import { WatcherState, getRuntimeComponentsPathsToWatch, watchAppSource, watchGlobalEntries, watchPages, watchPublicDirectory, watchRuntimeComponents, watchRuntimeConfiguration } from './watchers';
13
13
  import { createWebSocketServer } from './websocket';
14
14
 
15
+ const escapeForSingleQuotedJsString = (value: string): string => value.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
16
+
15
17
  /**
16
18
  * Performs cleanup of the application directory by removing the existing app directory if it exists.
17
19
  */
@@ -49,8 +51,7 @@ const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
49
51
  Logger.success(`Custom theme.css loaded`);
50
52
  }
51
53
 
52
- // Prepare project app configuration
53
- // Warning: Regex replacement is fragile and depends on exact formatting in next.config.mjs
54
+ // Prepare project app configuration using stable placeholder replacement.
54
55
  const handoffProjectId = handoff.getProjectId();
55
56
  const handoffAppBasePath = handoff.config.app.base_path ?? '';
56
57
  const handoffWorkingPath = path.resolve(handoff.workingPath);
@@ -59,15 +60,24 @@ const initializeProjectApp = async (handoff: Handoff): Promise<string> => {
59
60
  const nextConfigPath = path.resolve(srcPath, 'next.config.mjs');
60
61
  const targetPath = path.resolve(appPath, 'next.config.mjs');
61
62
  const handoffWebsocketPort = handoff.config.app.ports?.websocket ?? 3001;
62
- const nextConfigContent = (await fs.readFile(nextConfigPath, 'utf-8'))
63
- .replace(/basePath:\s+''/g, `basePath: '${handoffAppBasePath}'`)
64
- .replace(/HANDOFF_PROJECT_ID:\s+''/g, `HANDOFF_PROJECT_ID: '${handoffProjectId}'`)
65
- .replace(/HANDOFF_APP_BASE_PATH:\s+''/g, `HANDOFF_APP_BASE_PATH: '${handoffAppBasePath}'`)
66
- .replace(/HANDOFF_WORKING_PATH:\s+''/g, `HANDOFF_WORKING_PATH: '${handoffWorkingPath}'`)
67
- .replace(/HANDOFF_MODULE_PATH:\s+''/g, `HANDOFF_MODULE_PATH: '${handoffModulePath}'`)
68
- .replace(/HANDOFF_EXPORT_PATH:\s+''/g, `HANDOFF_EXPORT_PATH: '${handoffExportPath}'`)
69
- .replace(/HANDOFF_WEBSOCKET_PORT:\s+''/g, `HANDOFF_WEBSOCKET_PORT: '${handoffWebsocketPort}'`)
70
- .replace(/%HANDOFF_MODULE_PATH%/g, handoffModulePath);
63
+ const escapedAppBasePath = escapeForSingleQuotedJsString(handoffAppBasePath);
64
+ const escapedProjectId = escapeForSingleQuotedJsString(handoffProjectId);
65
+ const escapedWorkingPath = escapeForSingleQuotedJsString(handoffWorkingPath);
66
+ const escapedModulePath = escapeForSingleQuotedJsString(handoffModulePath);
67
+ const escapedExportPath = escapeForSingleQuotedJsString(handoffExportPath);
68
+ const escapedWebsocketPort = escapeForSingleQuotedJsString(String(handoffWebsocketPort));
69
+ const placeholderValues: Record<string, string> = {
70
+ '%HANDOFF_PROJECT_ID%': escapedProjectId,
71
+ '%HANDOFF_APP_BASE_PATH%': escapedAppBasePath,
72
+ '%HANDOFF_WORKING_PATH%': escapedWorkingPath,
73
+ '%HANDOFF_MODULE_PATH%': escapedModulePath,
74
+ '%HANDOFF_EXPORT_PATH%': escapedExportPath,
75
+ '%HANDOFF_WEBSOCKET_PORT%': escapedWebsocketPort,
76
+ };
77
+ let nextConfigContent = await fs.readFile(nextConfigPath, 'utf-8');
78
+ for (const [placeholder, value] of Object.entries(placeholderValues)) {
79
+ nextConfigContent = nextConfigContent.split(placeholder).join(value);
80
+ }
71
81
  await fs.writeFile(targetPath, nextConfigContent);
72
82
  return appPath;
73
83
  };
@@ -7,6 +7,7 @@ import { buildMainJS } from '../transformers/preview/component/javascript';
7
7
  import processComponents, { ComponentSegment } from '../transformers/preview/component/builder';
8
8
  import { ComponentListObject } from '../transformers/preview/types';
9
9
  import { Logger } from '../utils/logger';
10
+ import { isPathInside, normalizePathForCompare } from '../utils/path';
10
11
  import { persistClientConfig } from './client-config';
11
12
  import { syncPublicFiles } from './paths';
12
13
 
@@ -115,12 +116,6 @@ export const watchGlobalEntries = (handoff: Handoff, state: WatcherState, chokid
115
116
  const pathsToWatch = [...scssPathsToWatch, ...jsPathsToWatch];
116
117
  if (pathsToWatch.length === 0) return;
117
118
 
118
- const isPathInside = (filePath: string, parentPath: string): boolean => {
119
- const normalizedFilePath = path.resolve(filePath);
120
- const normalizedParentPath = path.resolve(parentPath);
121
- return normalizedFilePath === normalizedParentPath || normalizedFilePath.startsWith(`${normalizedParentPath}${path.sep}`);
122
- };
123
-
124
119
  chokidar.watch(pathsToWatch, chokidarConfig).on('all', async (event, file) => {
125
120
  switch (event) {
126
121
  case 'add':
@@ -206,11 +201,17 @@ export const watchRuntimeComponents = (
206
201
 
207
202
  if (runtimeComponentPathsToWatch.size > 0) {
208
203
  const pathsToWatch = Array.from(runtimeComponentPathsToWatch.keys());
204
+ const runtimeComponentEntryTypeByPath = new Map<string, keyof ComponentListObject['entries']>(
205
+ Array.from(runtimeComponentPathsToWatch.entries()).map(([watchPath, entryType]) => [normalizePathForCompare(watchPath), entryType])
206
+ );
207
+ const configFileSet = new Set(handoff.getConfigFilePaths().map((configPath) => normalizePathForCompare(configPath)));
208
+
209
209
  state.runtimeComponentsWatcher = chokidar.watch(pathsToWatch, {
210
210
  ignoreInitial: true,
211
211
  });
212
212
  state.runtimeComponentsWatcher.on('all', async (event, file) => {
213
- if (handoff.getConfigFilePaths().includes(file)) {
213
+ const normalizedFile = normalizePathForCompare(file);
214
+ if (configFileSet.has(normalizedFile)) {
214
215
  return;
215
216
  }
216
217
 
@@ -221,7 +222,7 @@ export const watchRuntimeComponents = (
221
222
  if (!state.debounce) {
222
223
  state.debounce = true;
223
224
  try {
224
- const entryType = runtimeComponentPathsToWatch.get(file);
225
+ const entryType = runtimeComponentEntryTypeByPath.get(normalizedFile);
225
226
  const segmentToUpdate: ComponentSegment = entryType ? mapEntryTypeToSegment(entryType) : undefined;
226
227
 
227
228
  const componentDir = path.basename(path.dirname(file));
@@ -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.1';
23
23
  };
24
24
 
25
25
  /**
@@ -158,6 +158,17 @@ export function handlebarsPreviewsPlugin(
158
158
  // Process component instances from documentation
159
159
  processComponentInstances(componentData, documentationComponents);
160
160
 
161
+ if (!componentData.previews || Object.keys(componentData.previews).length === 0) {
162
+ Logger.warn(`No previews defined for ${componentId}; using default preview values.`);
163
+ componentData.previews = {
164
+ default: {
165
+ title: 'Default',
166
+ values: {},
167
+ url: '',
168
+ },
169
+ };
170
+ }
171
+
161
172
  const generatedPreviews: { [key: string]: string } = {};
162
173
 
163
174
  // Generate previews for each variation
@@ -202,7 +213,8 @@ export function handlebarsPreviewsPlugin(
202
213
  componentData.format = PLUGIN_CONSTANTS.OUTPUT_FORMAT;
203
214
  componentData.preview = '';
204
215
  componentData.code = trimPreview(templateContent);
205
- componentData.html = trimPreview(generatedPreviews[Object.keys(generatedPreviews)[0]]);
216
+ const firstGeneratedPreview = Object.values(generatedPreviews)[0];
217
+ componentData.html = trimPreview(firstGeneratedPreview ?? templateContent);
206
218
  },
207
219
  };
208
220
  }
@@ -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);
@@ -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
  /**
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
+
package/handoff.config.js DELETED
@@ -1,90 +0,0 @@
1
- /** @type {import('handoff-app').Config} */
2
- const path = require('path');
3
- module.exports = {
4
- // Set your Figma project ID here or in the environment variable HANDOFF_FIGMA_PROJECT_ID
5
- figma_project_id: null,
6
- // Set the output directory for the exported components
7
- exportsOutputDirectory: 'exported',
8
- // Set the output directory for the sites
9
- sitesOutputDirectory: 'out',
10
- // These settings control the behavior of the application
11
- app: {
12
- // Set the theme for the application
13
- theme: 'default',
14
- // Set the title for the application
15
- title: 'Handoff Design System',
16
- // Set the client for the application, used in the footer
17
- client: 'Handoff Technologies',
18
- // Set the Google Tag Manager ID to add GTM scripts
19
- google_tag_manager: null,
20
- // Set the attribution for the application
21
- attribution: true, // Should we remove this?
22
- // Set the type copy for the typeography page
23
- type_copy: 'Almost before we knew it, we had left the ground.',
24
- // Set the type sort for the typeography page
25
- type_sort: [
26
- 'Heading 1',
27
- 'Heading 2',
28
- 'Heading 3',
29
- 'Heading 4',
30
- 'Heading 5',
31
- 'Heading 6',
32
- 'Paragraph',
33
- 'Subheading',
34
- 'Blockquote',
35
- 'Input Labels',
36
- 'Link',
37
- ],
38
- // Set the color sort for the color page
39
- color_sort: ['primary', 'secondary', 'extra', 'system'],
40
- // Set the component sort for the component page
41
- component_sort: ['primary', 'secondary', 'transparent'],
42
- // Set the base path for the application
43
- base_path: '',
44
- // Set the breakpoints for the application
45
- breakpoints: {
46
- sm: { size: 576, name: 'Small' },
47
- md: { size: 768, name: 'Medium' },
48
- lg: { size: 992, name: 'Large' },
49
- xl: { size: 1200, name: 'Extra Large' },
50
- },
51
- // Set the ports for the application
52
- ports: { app: 3000, websocket: 3001 },
53
- },
54
- // These are the entry points used to compile the target application
55
- entries: {
56
- scss: './sass/main.scss',
57
- js: './js/main.js',
58
- bundle: './js/main.js',
59
- components: ['./test_components'],
60
- patterns: ['./test_patterns'],
61
- },
62
- // These are the asset zip file download links
63
- assets_zip_links: {
64
- icons: null,
65
- logos: null,
66
- },
67
- // Hooks allow you to override the default behavior of the application
68
- hooks: {
69
- // Alter the vite css build config
70
- cssBuildConfig: (config) => {
71
- return config;
72
- },
73
- // Validate each component before it is exported
74
- validateComponent: (component) => {
75
- return {};
76
- },
77
- // Alter the vite client build config
78
- clientBuildConfig: (config) => {
79
- return config;
80
- },
81
- // Alter the vite html build config
82
- htmlBuildConfig: (config) => {
83
- return config;
84
- },
85
- // Alter the vite js build config
86
- jsBuildConfig: (config) => {
87
- return config;
88
- },
89
- },
90
- };