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 +8 -0
- package/dist/app-builder/build.js +20 -11
- package/dist/app-builder/watchers.js +8 -9
- package/dist/cache/build-cache.js +25 -10
- package/dist/cli.js +1 -1
- package/dist/transformers/plugins/handlebars-previews.js +12 -1
- package/dist/transformers/preview/component/builder.js +16 -1
- package/dist/transformers/utils/html.js +15 -3
- package/dist/utils/path.d.ts +15 -0
- package/dist/utils/path.js +31 -0
- package/package.json +1 -1
- package/src/app/components/Component/ComponentLists.tsx +5 -4
- package/src/app/components/Navigation/MainNav.tsx +3 -5
- package/src/app/components/Navigation/SideNav.tsx +4 -7
- package/src/app/components/SideNav/Custom.tsx +5 -3
- package/src/app/components/ui/mobile-nav.tsx +10 -7
- package/src/app/components/util/index.ts +2 -2
- package/src/app/lib/utils.ts +13 -0
- package/src/app/next.config.mjs +21 -13
- package/src/app/pages/system/component/[component]/index.tsx +1 -1
- package/src/app-builder/build.ts +21 -11
- package/src/app-builder/watchers.ts +9 -8
- package/src/cache/build-cache.ts +29 -11
- package/src/cli.ts +1 -1
- package/src/transformers/plugins/handlebars-previews.ts +13 -1
- package/src/transformers/preview/component/builder.ts +17 -1
- package/src/transformers/utils/html.ts +16 -3
- package/src/utils/path.ts +29 -0
- package/handoff.config.js +0 -90
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
146
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
/**
|
package/dist/utils/path.d.ts
CHANGED
|
@@ -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;
|
package/dist/utils/path.js
CHANGED
|
@@ -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
|
@@ -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
|
-
<
|
|
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
|
-
|
|
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}
|
|
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
|
-
<
|
|
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 =
|
|
38
|
+
const isActive = normalizePathForMatch(path) === normalizePathForMatch(router.asPath);
|
|
42
39
|
return (
|
|
43
40
|
<SidebarMenuItem>
|
|
44
41
|
<SidebarMenuButton asChild isActive={isActive}>
|
|
45
|
-
<a href={
|
|
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) =>
|
|
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={
|
|
18
|
+
<NavLink href={toAbsolutePath(item.path)}>
|
|
17
19
|
{item.title}
|
|
18
20
|
</NavLink>
|
|
19
21
|
{item.menu && (
|
|
20
|
-
<ul className={`c-sidenav__sub${
|
|
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={
|
|
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
|
|
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
|
|
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={
|
|
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
|
|
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={
|
|
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) => {
|
package/src/app/lib/utils.ts
CHANGED
|
@@ -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
|
package/src/app/next.config.mjs
CHANGED
|
@@ -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) +
|
|
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">
|
package/src/app-builder/build.ts
CHANGED
|
@@ -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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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 =
|
|
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));
|
package/src/cache/build-cache.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
};
|