handoff-app 1.1.0 → 1.1.2-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/Changelog.md +58 -0
  2. package/README.md +7 -0
  3. package/dist/app-builder/build.js +20 -11
  4. package/dist/app-builder/watchers.js +8 -9
  5. package/dist/cache/build-cache.js +25 -10
  6. package/dist/cli.js +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/transformers/plugins/handlebars-previews.js +17 -5
  9. package/dist/transformers/preview/component/builder.js +16 -1
  10. package/dist/transformers/utils/handlebars.d.ts +4 -2
  11. package/dist/transformers/utils/handlebars.js +10 -5
  12. package/dist/transformers/utils/html.js +15 -3
  13. package/dist/types/config.d.ts +25 -0
  14. package/dist/utils/path.d.ts +15 -0
  15. package/dist/utils/path.js +31 -0
  16. package/docs/api.md +65 -71
  17. package/package.json +1 -1
  18. package/src/app/components/Component/ComponentLists.tsx +5 -4
  19. package/src/app/components/Navigation/MainNav.tsx +3 -5
  20. package/src/app/components/Navigation/SideNav.tsx +4 -7
  21. package/src/app/components/SideNav/Custom.tsx +5 -3
  22. package/src/app/components/ui/mobile-nav.tsx +10 -7
  23. package/src/app/components/util/index.ts +2 -2
  24. package/src/app/lib/utils.ts +13 -0
  25. package/src/app/next.config.mjs +21 -13
  26. package/src/app/pages/system/component/[component]/index.tsx +1 -1
  27. package/src/app-builder/build.ts +21 -11
  28. package/src/app-builder/watchers.ts +9 -8
  29. package/src/cache/build-cache.ts +29 -11
  30. package/src/cli.ts +1 -1
  31. package/src/index.ts +1 -1
  32. package/src/transformers/plugins/handlebars-previews.ts +22 -6
  33. package/src/transformers/preview/component/builder.ts +17 -1
  34. package/src/transformers/utils/handlebars.ts +17 -9
  35. package/src/transformers/utils/html.ts +16 -3
  36. package/src/types/config.ts +25 -0
  37. package/src/utils/path.ts +29 -0
  38. package/templates/default/handoff.config.js.tpl +16 -0
  39. package/templates/default/handoff.config.with-examples.js.tpl +16 -0
package/docs/api.md CHANGED
@@ -78,70 +78,65 @@ to generate only the integration code.
78
78
 
79
79
  ## Hooks
80
80
 
81
- Hooks allow a typescript or javascript app to interact with the pipeline.
82
- This is especially useful for modifying or extending the output of the pipeline.
83
- To use a hook in your project, once you've instantiated the handoff object, you
84
- can call one of the hook methods and pass a function to the method. The hook
85
- function will be called at the appropriate point in the pipeline.
86
-
87
- For example if you want to alter the integration output, you can do this -
81
+ Hooks let you extend the build and preview pipeline. Configure them in
82
+ `handoff.config.js` (or `.cjs`) under the top-level `hooks` object. Hook names
83
+ use camelCase (for example `validateComponent`, `jsBuildConfig`,
84
+ `registerHandlebarsHelpers`). The Handoff CLI and Node API load this config when
85
+ the `Handoff` instance is created.
88
86
 
89
87
  ```js
90
- import Handoff from 'handoff-app';
88
+ // handoff.config.js
89
+ module.exports = {
90
+ // …
91
+ hooks: {
92
+ registerHandlebarsHelpers: ({ handlebars, componentId }) => {
93
+ handlebars.registerHelper('upperId', () => componentId.toUpperCase());
94
+ },
95
+ },
96
+ };
97
+ ```
91
98
 
92
- const handoff = new Handoff({
93
- // You can customize the configuration here
94
- });
99
+ ### registerHandlebarsHelpers
95
100
 
96
- handoff.postIntegration((documentationObject: DocumentationObject, data: HookReturn[]) => {
97
- const colors = documentationObject.design.color.map((color) => {
98
- return {
99
- name: color.name,
100
- value: color.value,
101
- };
102
- });
103
- data.push({
104
- filename: 'colors.json',
105
- data: JSON.stringify(colors, null, 2),
106
- });
107
- return data;
108
- });
101
+ Called immediately after Handoff registers built-in Handlebars helpers (`field`
102
+ and `eq`) while rendering **Handlebars** component previews (static HTML per
103
+ variation). Use it to call `handlebars.registerHelper` for custom helpers used
104
+ in your `.hbs` templates.
109
105
 
110
- handoff.fetch();
111
- ```
106
+ **Arguments (single context object)**
112
107
 
113
- ### postIntegration
108
+ * `handlebars` — The Handlebars runtime (same as importing `handlebars`); use
109
+ `registerHelper`, `SafeString`, etc.
110
+ * `componentId` — The current component’s id string.
111
+ * `properties` — The component’s slot metadata map from the schema (same shape
112
+ as used by the `field` helper).
113
+ * `injectFieldWrappers` — `true` when generating inspect-mode previews (field
114
+ inspection markup), `false` for normal previews.
114
115
 
115
- The Post integration hook will allow you to add a function that will be executed
116
- after the integration build is complete. The function accepts two arguments -
116
+ **Return**
117
117
 
118
- **arguments**
118
+ * `void` — register helpers on `handlebars` directly.
119
119
 
120
- - `tokens: DocumentationObject` This is an instance of the exported
121
- DocumentationObject containing all of the tokens.
122
- - `data: HookReturn[]` This an array of HookReturns, consisting of `filename`
123
- and `data`. Each object in this array will be written out to
124
- `exported/{integration}-tokens`
120
+ **Notes**
125
121
 
126
- **return**
127
- The hook must return an array of `HookReturn` objects, or an empty array
122
+ * This hook runs once per preview render (each variation × normal vs inspect).
123
+ * Registering the same helper name again replaces any previous registration for
124
+ subsequent renders in the same process.
128
125
 
129
- #### Example
126
+ **Example**
130
127
 
131
128
  ```js
132
- handoff.postIntegration((documentationObject: DocumentationObject, data: HookReturn[]) => {
133
- const colors = documentationObject.design.color.map((color) => {
134
- return {
135
- name: color.name,
136
- value: color.value,
137
- };
138
- });
139
- data.push({
140
- filename: 'colors.json',
141
- data: JSON.stringify(colors, null, 2),
142
- });
143
- return data;
144
- });
129
+ // handoff.config.js
130
+ module.exports = {
131
+ hooks: {
132
+ registerHandlebarsHelpers: ({ handlebars, componentId, injectFieldWrappers }) => {
133
+ handlebars.registerHelper('debugPreview', function () {
134
+ return injectFieldWrappers ? '[inspect]' : '[preview]';
135
+ });
136
+ handlebars.registerHelper('componentLabel', () => componentId);
137
+ },
138
+ },
139
+ };
145
140
  ```
146
141
 
147
142
  ### postBuild
@@ -150,7 +145,7 @@ This function is called after the app build is complete.
150
145
 
151
146
  **arguments**
152
147
 
153
- - `tokens: DocumentationObject` This is an instance of the exported
148
+ * `tokens: DocumentationObject` This is an instance of the exported
154
149
  DocumentationObject containing all of the tokens.
155
150
 
156
151
  **returns**
@@ -163,14 +158,14 @@ application to alter the css generation in transit
163
158
 
164
159
  **arguments**
165
160
 
166
- - `tokens: DocumentationObject` This is an instance of the exported
161
+ * `tokens: DocumentationObject` This is an instance of the exported
167
162
  DocumentationObject containing all of the tokens.
168
- - `css: CssTransformerOutput` This is an object containing all of the
163
+ * `css: CssTransformerOutput` This is an object containing all of the
169
164
  tokens for components and foundations, formatted as CSS variables
170
165
 
171
166
  **returns**
172
167
 
173
- - `css: CssTransformerOutput` This is an object containing all of the
168
+ * `css: CssTransformerOutput` This is an object containing all of the
174
169
  tokens for components and foundations, formatted as CSS variables.
175
170
  Any changes you return here will be written to the css files
176
171
 
@@ -182,14 +177,14 @@ the transformed output prior to being written to disk.
182
177
 
183
178
  **arguments**
184
179
 
185
- - `tokens: DocumentationObject` This is an instance of the exported
180
+ * `tokens: DocumentationObject` This is an instance of the exported
186
181
  DocumentationObject containing all of the tokens.
187
- - `css: CssTransformerOutput` This is an object containing all of the
182
+ * `css: CssTransformerOutput` This is an object containing all of the
188
183
  tokens for components and foundations, formatted as CSS variables
189
184
 
190
185
  **returns**
191
186
 
192
- - `css: CssTransformerOutput` This is an object containing all of the
187
+ * `css: CssTransformerOutput` This is an object containing all of the
193
188
  tokens for components and foundations, formatted as CSS variables.
194
189
  Any changes you return here will be written to the css files.
195
190
 
@@ -201,14 +196,14 @@ the transformed output prior to being written to disk.
201
196
 
202
197
  **arguments**
203
198
 
204
- - `tokens: DocumentationObject` This is an instance of the exported
199
+ * `tokens: DocumentationObject` This is an instance of the exported
205
200
  DocumentationObject containing all of the tokens.
206
- - `scss: CssTransformerOutput` This is an object containing all of the
201
+ * `scss: CssTransformerOutput` This is an object containing all of the
207
202
  tokens for components and foundations, formatted as SCSS variables
208
203
 
209
204
  **returns**
210
205
 
211
- - `css: CssTransformerOutput` This is an object containing all of the
206
+ * `css: CssTransformerOutput` This is an object containing all of the
212
207
  tokens for components and foundations, formatted as SCSS variables.
213
208
  Any changes you return here will be written to the scss files.
214
209
 
@@ -224,14 +219,14 @@ the transformed output prior to being written to disk.
224
219
 
225
220
  **arguments**
226
221
 
227
- - `tokens: DocumentationObject` This is an instance of the exported
222
+ * `tokens: DocumentationObject` This is an instance of the exported
228
223
  DocumentationObject containing all of the tokens.
229
- - `scss: CssTransformerOutput` This is an object containing all of the
224
+ * `scss: CssTransformerOutput` This is an object containing all of the
230
225
  type arrays for components and foundations, formatted as SCSS variables
231
226
 
232
227
  **returns**
233
228
 
234
- - `css: CssTransformerOutput` This is an object containing all of the
229
+ * `css: CssTransformerOutput` This is an object containing all of the
235
230
  tokens for components and foundations, formatted as type variables.
236
231
  Any changes you return here will be written to the scss type files.
237
232
 
@@ -244,11 +239,11 @@ and allows you to alter and return that configuration.
244
239
 
245
240
  **arguments**
246
241
 
247
- - `webpackConfig: webpack.Configuration` This is a full webpack configuration.
242
+ * `webpackConfig: webpack.Configuration` This is a full webpack configuration.
248
243
 
249
244
  **returns**
250
245
 
251
- - `webpackConfig: webpack.Configuration` Return the webpack configuration that
246
+ * `webpackConfig: webpack.Configuration` Return the webpack configuration that
252
247
  you have altered to fit your needs.
253
248
 
254
249
  **Example**
@@ -265,15 +260,14 @@ export const modifyWebpackConfigForTailwind = (webpackConfig: webpack.Configurat
265
260
 
266
261
  This hook allows you to alter the exportable list. You could do this by ejecting
267
262
  the handoff configuration and modifying the list, but this hook allows you to
268
- alter the exportable list with just a couple of lines of code rather than
263
+ alter the exportable list with just a couple of lines of code rather than
269
264
  exporting the whole list
270
265
 
271
266
  **arguments**
272
267
 
273
- - `exportables: string[]` The current list of exportables
268
+ * `exportables: string[]` The current list of exportables
274
269
 
275
270
  **returns**
276
271
 
277
- - `exportables: string[]` Return the list with whatever additions and subtractions
278
- you need for your application.
279
-
272
+ * `exportables: string[]` Return the list with whatever additions and subtractions
273
+ you need for your application.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "1.1.0",
3
+ "version": "1.1.2-0",
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));