handoff-app 1.1.3 → 1.2.0-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 (143) hide show
  1. package/config/config.template.js +21 -14
  2. package/config/config.template.ts +54 -0
  3. package/config/docs/system.md +2 -0
  4. package/dist/app-builder/build.js +7 -2
  5. package/dist/app-builder/config-diff/index.d.ts +4 -0
  6. package/dist/app-builder/config-diff/index.js +12 -0
  7. package/dist/app-builder/config-diff/registry.d.ts +9 -0
  8. package/dist/app-builder/config-diff/registry.js +30 -0
  9. package/dist/app-builder/config-diff/snapshot.d.ts +13 -0
  10. package/dist/app-builder/config-diff/snapshot.js +43 -0
  11. package/dist/app-builder/config-diff/strategies/pattern.d.ts +2 -0
  12. package/dist/app-builder/config-diff/strategies/pattern.js +93 -0
  13. package/dist/app-builder/config-diff/types.d.ts +19 -0
  14. package/dist/app-builder/config-diff/types.js +2 -0
  15. package/dist/app-builder/watchers/component.d.ts +15 -0
  16. package/dist/app-builder/watchers/component.js +69 -0
  17. package/dist/app-builder/watchers/utils.d.ts +14 -0
  18. package/dist/app-builder/watchers/utils.js +44 -0
  19. package/dist/app-builder/watchers.d.ts +10 -19
  20. package/dist/app-builder/watchers.js +56 -102
  21. package/dist/cache/build-cache.js +14 -5
  22. package/dist/cli/create.js +67 -23
  23. package/dist/cli/eject.js +48 -7
  24. package/dist/cli/make.js +29 -4
  25. package/dist/cli/scaffold.js +60 -19
  26. package/dist/cli.js +1 -1
  27. package/dist/config/helpers.d.ts +7 -0
  28. package/dist/config/helpers.js +31 -0
  29. package/dist/config/index.d.ts +2 -1
  30. package/dist/config/index.js +5 -1
  31. package/dist/config/loader.d.ts +10 -1
  32. package/dist/config/loader.js +92 -28
  33. package/dist/config/normalizers/declaration.d.ts +11 -0
  34. package/dist/config/normalizers/declaration.js +119 -0
  35. package/dist/config/normalizers/pattern.d.ts +9 -0
  36. package/dist/config/normalizers/pattern.js +38 -0
  37. package/dist/config/runtime.d.ts +9 -3
  38. package/dist/config/runtime.js +254 -33
  39. package/dist/declarations/helpers.d.ts +6 -0
  40. package/dist/declarations/helpers.js +19 -0
  41. package/dist/declarations/index.d.ts +2 -0
  42. package/dist/declarations/index.js +9 -0
  43. package/dist/declarations/types.d.ts +60 -0
  44. package/dist/declarations/types.js +2 -0
  45. package/dist/index.d.ts +12 -1
  46. package/dist/index.js +34 -5
  47. package/dist/pipeline/index.d.ts +1 -0
  48. package/dist/pipeline/index.js +3 -1
  49. package/dist/pipeline/patterns.d.ts +6 -0
  50. package/dist/pipeline/patterns.js +21 -0
  51. package/dist/transformers/docgen/index.js +12 -2
  52. package/dist/transformers/plugins/csf-render.js +170 -34
  53. package/dist/transformers/plugins/ssr-render.js +18 -2
  54. package/dist/transformers/preview/component/api.js +16 -7
  55. package/dist/transformers/preview/component/builder.js +9 -3
  56. package/dist/transformers/preview/component/css.d.ts +8 -0
  57. package/dist/transformers/preview/component/html.js +14 -5
  58. package/dist/transformers/preview/component/previews.d.ts +9 -0
  59. package/dist/transformers/preview/component/previews.js +10 -0
  60. package/dist/transformers/preview/pattern/api.d.ts +7 -0
  61. package/dist/transformers/preview/pattern/api.js +61 -0
  62. package/dist/transformers/preview/pattern/builder.d.ts +9 -0
  63. package/dist/transformers/preview/pattern/builder.js +86 -0
  64. package/dist/transformers/preview/pattern/html.d.ts +19 -0
  65. package/dist/transformers/preview/pattern/html.js +94 -0
  66. package/dist/transformers/preview/types.d.ts +54 -0
  67. package/dist/transformers/utils/csf.d.ts +16 -0
  68. package/dist/transformers/utils/csf.js +30 -0
  69. package/dist/transformers/utils/index.d.ts +1 -0
  70. package/dist/transformers/utils/index.js +2 -0
  71. package/dist/transformers/utils/module.d.ts +5 -0
  72. package/dist/transformers/utils/module.js +26 -13
  73. package/dist/transformers/utils/usage.d.ts +23 -0
  74. package/dist/transformers/utils/usage.js +294 -0
  75. package/dist/types/config.d.ts +32 -7
  76. package/dist/types/preview.d.ts +2 -0
  77. package/docs/cli.md +3 -3
  78. package/package.json +3 -1
  79. package/src/app/components/Component/PageSliceResolver.tsx +6 -6
  80. package/src/app/components/Component/Preview.tsx +17 -12
  81. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +16 -6
  82. package/src/app/components/Markdown/CodeHighlight.tsx +60 -11
  83. package/src/app/components/util/index.ts +71 -2
  84. package/src/app/pages/system/pattern/[pattern]/index.tsx +223 -0
  85. package/src/app/pages/system/pattern/index.tsx +14 -0
  86. package/src/app-builder/README.md +37 -9
  87. package/src/app-builder/build.ts +8 -2
  88. package/src/app-builder/config-diff/index.ts +4 -0
  89. package/src/app-builder/config-diff/registry.ts +23 -0
  90. package/src/app-builder/config-diff/snapshot.ts +51 -0
  91. package/src/app-builder/config-diff/strategies/pattern.ts +65 -0
  92. package/src/app-builder/config-diff/types.ts +21 -0
  93. package/src/app-builder/watchers/component.ts +72 -0
  94. package/src/app-builder/watchers/utils.ts +44 -0
  95. package/src/app-builder/watchers.ts +62 -80
  96. package/src/cache/build-cache.ts +14 -5
  97. package/src/cli/create.ts +86 -25
  98. package/src/cli/eject.ts +29 -7
  99. package/src/cli/make.ts +32 -5
  100. package/src/cli/scaffold.ts +68 -21
  101. package/src/cli.ts +1 -1
  102. package/src/config/README.md +10 -1
  103. package/src/config/helpers.ts +43 -0
  104. package/src/config/index.ts +4 -1
  105. package/src/config/loader.ts +110 -31
  106. package/src/config/normalizers/declaration.ts +176 -0
  107. package/src/config/normalizers/pattern.ts +63 -0
  108. package/src/config/runtime.ts +310 -35
  109. package/src/declarations/helpers.ts +36 -0
  110. package/src/declarations/index.ts +19 -0
  111. package/src/declarations/types.ts +70 -0
  112. package/src/index.ts +47 -6
  113. package/src/pipeline/index.ts +1 -0
  114. package/src/pipeline/patterns.ts +10 -0
  115. package/src/transformers/README.md +2 -2
  116. package/src/transformers/docgen/index.ts +13 -2
  117. package/src/transformers/plugins/csf-render.ts +216 -49
  118. package/src/transformers/plugins/ssr-render.ts +23 -7
  119. package/src/transformers/preview/component/api.ts +27 -7
  120. package/src/transformers/preview/component/builder.ts +21 -4
  121. package/src/transformers/preview/component/html.ts +18 -5
  122. package/src/transformers/preview/component/previews.ts +12 -0
  123. package/src/transformers/preview/pattern/api.ts +66 -0
  124. package/src/transformers/preview/pattern/builder.ts +94 -0
  125. package/src/transformers/preview/pattern/html.ts +107 -0
  126. package/src/transformers/preview/types.ts +58 -1
  127. package/src/transformers/utils/csf.ts +48 -0
  128. package/src/transformers/utils/index.ts +3 -0
  129. package/src/transformers/utils/module.ts +33 -18
  130. package/src/transformers/utils/usage.ts +319 -0
  131. package/src/types/README.md +1 -1
  132. package/src/types/config.ts +33 -7
  133. package/src/types/preview.ts +2 -0
  134. package/templates/default/components/button/Button.jsx.tpl +64 -0
  135. package/templates/default/components/button/button.react.handoff.js.tpl +31 -0
  136. package/templates/default/components/button/button.react.handoff.ts.tpl +31 -0
  137. package/templates/default/handoff.config.js.tpl +29 -16
  138. package/templates/default/handoff.config.ts.tpl +196 -0
  139. package/templates/default/handoff.config.with-examples.js.tpl +28 -17
  140. package/templates/default/handoff.config.with-examples.ts.tpl +195 -0
  141. package/templates/default/package.json.tpl +1 -3
  142. package/handoff.config.js +0 -90
  143. package/templates/default/components/button/button.js.tpl +0 -62
package/src/cli/eject.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as p from '@clack/prompts';
1
2
  import fs from 'fs-extra';
2
3
  import path from 'path';
3
4
  import Handoff from '../index';
@@ -8,14 +9,35 @@ import { Logger } from '../utils/logger';
8
9
  * @param handoff
9
10
  */
10
11
  export const ejectConfig = async (handoff: Handoff) => {
11
- const configPath = path.resolve(path.join(handoff.workingPath, 'handoff.config.js'));
12
- if (fs.existsSync(configPath)) {
13
- if (!handoff.force) {
14
- Logger.error(`Config file already exists. Use "--force" to overwrite.`);
15
- }
12
+ const existingConfigNames = ['handoff.config.ts', 'handoff.config.js', 'handoff.config.cjs', 'handoff.config.json'];
13
+ const existingConfigPaths = existingConfigNames
14
+ .map((name) => path.resolve(path.join(handoff.workingPath, name)))
15
+ .filter((filePath) => fs.existsSync(filePath));
16
+ if (existingConfigPaths.length > 0 && !handoff.force) {
17
+ Logger.error(`Config file already exists. Use "--force" to overwrite.`);
18
+ return handoff;
16
19
  }
17
- // load the template as a string
18
- const template = fs.readFileSync(path.resolve(handoff.modulePath, 'config/config.template.js'), 'utf8');
20
+
21
+ const defaultFormat = fs.existsSync(path.resolve(handoff.workingPath, 'tsconfig.json')) ? 'typescript' : 'javascript';
22
+ const formatSelection = await p.select({
23
+ message: 'Which config format would you like to eject?',
24
+ options: [
25
+ { value: 'typescript', label: 'TypeScript', hint: 'Generates handoff.config.ts' },
26
+ { value: 'javascript', label: 'JavaScript', hint: 'Generates handoff.config.js' },
27
+ ],
28
+ initialValue: defaultFormat,
29
+ });
30
+
31
+ if (p.isCancel(formatSelection)) {
32
+ Logger.warn('Config eject cancelled.');
33
+ return handoff;
34
+ }
35
+
36
+ const useTypeScript = formatSelection === 'typescript';
37
+ const configPath = path.resolve(path.join(handoff.workingPath, useTypeScript ? 'handoff.config.ts' : 'handoff.config.js'));
38
+ const templatePath = path.resolve(handoff.modulePath, useTypeScript ? 'config/config.template.ts' : 'config/config.template.js');
39
+
40
+ const template = fs.readFileSync(templatePath, 'utf8');
19
41
  fs.writeFileSync(configPath, template);
20
42
  Logger.success(`Config ejected to ${configPath}`);
21
43
  return handoff;
package/src/cli/make.ts CHANGED
@@ -123,7 +123,7 @@ export const makeComponent = async (handoff: Handoff, name: string) => {
123
123
  } else {
124
124
  componentsRoot = path.resolve(handoff.workingPath, DEFAULT_COMPONENTS_DIR);
125
125
  Logger.warn(
126
- `No entries.components configured in handoff.config.js. ` +
126
+ `No entries.components configured in handoff.config.*. ` +
127
127
  `Scaffolding into "${DEFAULT_COMPONENTS_DIR}/". ` +
128
128
  `Add this path to entries.components in your config so the build picks it up.`
129
129
  );
@@ -146,10 +146,6 @@ export const makeComponent = async (handoff: Handoff, name: string) => {
146
146
  fs.writeFileSync(targetHtml, htmlTemplate);
147
147
  Logger.success(`New component ${name}.hbs was created in ${workingPath}`);
148
148
 
149
- const jsonpath = path.resolve(templatePath, 'template.json');
150
- const jsonTemplate = fs.readFileSync(jsonpath, 'utf8');
151
- fs.writeFileSync(path.resolve(workingPath, `${name}.json`), jsonTemplate);
152
-
153
149
  const writeJSFile = await p.confirm({
154
150
  message: `Generate a supporting javascript file ${name}.js?`,
155
151
  initialValue: false,
@@ -172,5 +168,36 @@ export const makeComponent = async (handoff: Handoff, name: string) => {
172
168
  fs.writeFileSync(path.resolve(workingPath, `${name}.scss`), scssTemplate);
173
169
  }
174
170
 
171
+ const declarationEntries = [`template: './${name}.hbs'`];
172
+ if (writeJSFile === true) {
173
+ declarationEntries.push(`js: './${name}.js'`);
174
+ }
175
+ if (writeSassFile === true) {
176
+ declarationEntries.push(`scss: './${name}.scss'`);
177
+ }
178
+
179
+ const declarationContent = `const { defineHandlebarsComponent } = require('handoff-app');
180
+
181
+ module.exports = defineHandlebarsComponent({
182
+ id: '${name}',
183
+ name: '',
184
+ description: '',
185
+ group: '',
186
+ type: 'element',
187
+ entries: {
188
+ ${declarationEntries.join(',\n ')}
189
+ },
190
+ previews: {
191
+ default: {
192
+ title: 'Default',
193
+ args: {}
194
+ }
195
+ }
196
+ });
197
+ `;
198
+
199
+ fs.writeFileSync(path.resolve(workingPath, `${name}.handoff.js`), declarationContent);
200
+ Logger.success(`New component declaration ${name}.handoff.js was created in ${workingPath}`);
201
+
175
202
  return handoff;
176
203
  };
@@ -5,9 +5,8 @@ import path from 'path';
5
5
  import Handoff from '../index';
6
6
 
7
7
  // Constants
8
- const CONFIG_FILES = ['handoff.config.json', 'handoff.config.js', 'handoff.config.cjs'] as const;
8
+ const CONFIG_FILES = ['handoff.config.ts', 'handoff.config.js', 'handoff.config.cjs', 'handoff.config.json'] as const;
9
9
  const COMPONENTS_DIR = 'components';
10
- const DEFAULT_VERSION = '1.0.0';
11
10
  const DEFAULT_GROUPS = [
12
11
  { value: 'Atomic Elements', label: 'Atomic Elements' },
13
12
  { value: 'Forms', label: 'Forms' },
@@ -47,27 +46,50 @@ const toTitleCase = (str: string): string => {
47
46
  * Generate the component JS stub content
48
47
  */
49
48
  const generateComponentStub = (config: ComponentConfig): string => {
50
- // Build entries object with relative paths to generated files
51
49
  const entriesLines: string[] = [];
52
50
  if (config.generateTsx) {
53
- entriesLines.push(` template: './${config.name}.tsx',`);
51
+ entriesLines.push(` component: './${config.name}.tsx',`);
52
+ } else {
53
+ entriesLines.push(` template: './${config.name}.hbs',`);
54
54
  }
55
55
  if (config.generateScss) {
56
56
  entriesLines.push(` scss: './${config.name}.scss',`);
57
57
  }
58
58
 
59
- const entriesBlock = entriesLines.length > 0
60
- ? ` entries: {\n${entriesLines.join('\n')}\n },\n`
61
- : '';
62
-
63
- return `/** @type {import('handoff-app').Component} */
64
- module.exports = {
65
- title: "${config.title}",
59
+ const commonMetadata = ` id: "${config.name}",
60
+ name: "${config.title}",
66
61
  description: "${config.description}",
67
62
  group: "${config.group}",
68
63
  type: "element",
69
- figmaComponentId: "${config.name}",
70
- ${entriesBlock}};
64
+ figmaComponentId: "${config.name}",`;
65
+
66
+ if (config.generateTsx) {
67
+ return `const { defineReactComponent } = require('handoff-app');
68
+ const ${toTitleCase(config.name).replace(/\s/g, '')} = require('./${config.name}').default;
69
+
70
+ module.exports = defineReactComponent(${toTitleCase(config.name).replace(/\s/g, '')}, {
71
+ ${commonMetadata}
72
+ entries: {
73
+ ${entriesLines.join('\n')}
74
+ },
75
+ previews: {
76
+ default: { title: "Default", args: {} }
77
+ }
78
+ });
79
+ `;
80
+ }
81
+
82
+ return `const { defineHandlebarsComponent } = require('handoff-app');
83
+
84
+ module.exports = defineHandlebarsComponent({
85
+ ${commonMetadata}
86
+ entries: {
87
+ ${entriesLines.join('\n')}
88
+ },
89
+ previews: {
90
+ default: { title: "Default", args: {} }
91
+ }
92
+ });
71
93
  `;
72
94
  };
73
95
 
@@ -252,8 +274,8 @@ const updateConfigFile = async (
252
274
  return { success: true, isJsConfig: false, configPath, skipped: false };
253
275
  }
254
276
 
255
- // For JS/CJS config files, try to update them programmatically
256
- if (configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
277
+ // For code config files, try to update them programmatically
278
+ if (configFile.endsWith('.ts') || configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
257
279
  try {
258
280
  let content = await fs.readFile(configPath, 'utf8');
259
281
 
@@ -309,7 +331,7 @@ const updateConfigFile = async (
309
331
  return { success: true, isJsConfig: true, configPath, skipped: false };
310
332
  }
311
333
 
312
- // No entries object, try to add it before the closing of module.exports
334
+ // No entries object, try to add it before the closing of exported object
313
335
  const formattedArray = formatComponentsArray(newComponentPaths);
314
336
  const entriesBlock = ` entries: {\n components: ${formattedArray},\n },\n`;
315
337
 
@@ -322,6 +344,26 @@ const updateConfigFile = async (
322
344
  await fs.writeFile(configPath, content, 'utf8');
323
345
  return { success: true, isJsConfig: true, configPath, skipped: false };
324
346
  }
347
+
348
+ // Try to insert after export default {
349
+ if (/export\s+default\s+\{/.test(content)) {
350
+ content = content.replace(
351
+ /(export\s+default\s+\{)/,
352
+ `$1\n${entriesBlock}`
353
+ );
354
+ await fs.writeFile(configPath, content, 'utf8');
355
+ return { success: true, isJsConfig: true, configPath, skipped: false };
356
+ }
357
+
358
+ // Try to insert into defineConfig({ ... })
359
+ if (/defineConfig\s*\(\s*\{/.test(content)) {
360
+ content = content.replace(
361
+ /(defineConfig\s*\(\s*\{)/,
362
+ `$1\n${entriesBlock}`
363
+ );
364
+ await fs.writeFile(configPath, content, 'utf8');
365
+ return { success: true, isJsConfig: true, configPath, skipped: false };
366
+ }
325
367
 
326
368
  // Fallback: couldn't parse the JS config
327
369
  return { success: false, isJsConfig: true, configPath, skipped: false };
@@ -344,15 +386,14 @@ const createComponentFiles = async (
344
386
  const componentDir = path.resolve(
345
387
  handoff.workingPath,
346
388
  COMPONENTS_DIR,
347
- config.name,
348
- DEFAULT_VERSION
389
+ config.name
349
390
  );
350
391
 
351
392
  // Ensure directory exists
352
393
  await fs.ensureDir(componentDir);
353
394
 
354
- // Create the main component JS file
355
- const jsPath = path.join(componentDir, `${config.name}.js`);
395
+ // Create the main component declaration file
396
+ const jsPath = path.join(componentDir, `${config.name}.handoff.js`);
356
397
  await fs.writeFile(jsPath, generateComponentStub(config));
357
398
  createdFiles.push(jsPath);
358
399
 
@@ -361,6 +402,12 @@ const createComponentFiles = async (
361
402
  const tsxPath = path.join(componentDir, `${config.name}.tsx`);
362
403
  await fs.writeFile(tsxPath, generateReactComponentStub(config.name, config.variantProps));
363
404
  createdFiles.push(tsxPath);
405
+ } else {
406
+ const hbsTemplatePath = path.resolve(handoff.modulePath, 'config/templates/component/template.hbs');
407
+ const hbsTemplate = await fs.readFile(hbsTemplatePath, 'utf8');
408
+ const hbsPath = path.join(componentDir, `${config.name}.hbs`);
409
+ await fs.writeFile(hbsPath, hbsTemplate);
410
+ createdFiles.push(hbsPath);
364
411
  }
365
412
 
366
413
  // Optionally create SCSS file
@@ -380,7 +427,7 @@ const displayComponentPreview = (config: ComponentConfig): void => {
380
427
  const stub = generateComponentStub(config);
381
428
  const lines = stub.split('\n');
382
429
  const preview = lines.slice(0, 12).join('\n') + '\n ...\n};';
383
- p.note(preview, `Preview: ${config.name}.js`);
430
+ p.note(preview, `Preview: ${config.name}.handoff.js`);
384
431
  };
385
432
 
386
433
  /**
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.3';
22
+ return 'Handoff App - 1.2.0-0';
23
23
  };
24
24
 
25
25
  /**
@@ -7,7 +7,7 @@ Handles loading, validation, and resolution of handoff configuration.
7
7
  | File | Purpose |
8
8
  |------|---------|
9
9
  | `defaults.ts` | Default config values and `getClientConfig()` for the Next.js app |
10
- | `loader.ts` | `initConfig()` — reads `handoff.config.json/.js/.cjs` and merges overrides |
10
+ | `loader.ts` | `initConfig()` — reads `handoff.config.ts/.js/.cjs/.json` (in that order), warns on conflicts, and merges overrides |
11
11
  | `runtime.ts` | `initRuntimeConfig()` — resolves component entries, SCSS/JS paths, transformer options |
12
12
  | `validator.ts` | `validateConfig()` — ensures required Figma credentials are present |
13
13
  | `index.ts` | Barrel re-exports |
@@ -21,3 +21,12 @@ const config = initConfig(overrides);
21
21
  const [runtimeConfig, configPaths] = initRuntimeConfig({ config, workingPath });
22
22
  const validated = validateConfig(config);
23
23
  ```
24
+
25
+ ## Main Config Precedence
26
+
27
+ When multiple main config files exist in the project root, Handoff picks the first match in this order and warns about ignored files:
28
+
29
+ 1. `handoff.config.ts`
30
+ 2. `handoff.config.js`
31
+ 3. `handoff.config.cjs`
32
+ 4. `handoff.config.json`
@@ -0,0 +1,43 @@
1
+ import { Config } from '../types/config';
2
+
3
+ const normalizeAppConfig = (app?: Config['app']): Config['app'] => {
4
+ if (!app) return app;
5
+
6
+ const {
7
+ googleTagManager,
8
+ typeCopy,
9
+ typeSort,
10
+ colorSort,
11
+ componentSort,
12
+ basePath,
13
+ ...rest
14
+ } = app;
15
+
16
+ return {
17
+ ...rest,
18
+ google_tag_manager: googleTagManager ?? app.google_tag_manager,
19
+ type_copy: typeCopy ?? app.type_copy,
20
+ type_sort: typeSort ?? app.type_sort,
21
+ color_sort: colorSort ?? app.color_sort,
22
+ component_sort: componentSort ?? app.component_sort,
23
+ base_path: basePath ?? app.base_path,
24
+ };
25
+ };
26
+
27
+ export const normalizeConfig = (config: Config): Config => {
28
+ const { devAccessToken, figmaProjectId, assetsZipLinks, app, ...rest } = config;
29
+
30
+ return {
31
+ ...(rest as Config),
32
+ dev_access_token: devAccessToken ?? config.dev_access_token,
33
+ figma_project_id: figmaProjectId ?? config.figma_project_id,
34
+ assets_zip_links: assetsZipLinks ?? config.assets_zip_links,
35
+ app: normalizeAppConfig(app),
36
+ };
37
+ };
38
+
39
+ /**
40
+ * Optional helper for strongly-typed config authoring.
41
+ * It normalizes modern camelCase keys to legacy runtime keys.
42
+ */
43
+ export const defineConfig = (config: Config): Config => normalizeConfig(config);
@@ -3,7 +3,10 @@ export { defaultConfig, getClientConfig } from './defaults';
3
3
  export type { ImageStyle } from './defaults';
4
4
 
5
5
  // Config file loading
6
- export { initConfig } from './loader';
6
+ export { initConfig, initConfigWithMetadata } from './loader';
7
+
8
+ // Config helpers
9
+ export { defineConfig } from './helpers';
7
10
 
8
11
  // Runtime config resolution
9
12
  export { initRuntimeConfig } from './runtime';
@@ -1,48 +1,127 @@
1
+ import esbuild from 'esbuild';
1
2
  import fs from 'fs-extra';
3
+ import { createRequire } from 'module';
2
4
  import path from 'path';
3
5
  import { Config } from '../types/config';
6
+ import { Logger } from '../utils/logger';
4
7
  import { defaultConfig } from './defaults';
5
8
 
9
+ const CONFIG_FILE_PREFERENCE = [
10
+ 'handoff.config.ts',
11
+ 'handoff.config.js',
12
+ 'handoff.config.cjs',
13
+ 'handoff.config.json',
14
+ ] as const;
15
+
16
+ type ConfigLoadResult = {
17
+ config: Config;
18
+ configPath?: string;
19
+ };
20
+
21
+ const evaluateTypeScriptConfig = (filePath: string, handoffModulePath: string): any => {
22
+ const buildResult = esbuild.buildSync({
23
+ entryPoints: [filePath],
24
+ bundle: true,
25
+ write: false,
26
+ platform: 'node',
27
+ format: 'cjs',
28
+ target: 'node16',
29
+ logLevel: 'silent',
30
+ external: ['handoff-app'],
31
+ });
32
+
33
+ const code = buildResult.outputFiles?.[0]?.text;
34
+ if (!code) {
35
+ throw new Error(`Unable to compile config file "${filePath}"`);
36
+ }
37
+
38
+ const mod: any = { exports: {} };
39
+ const localRequire = createRequire(filePath);
40
+ const handoffRequire = createRequire(path.resolve(handoffModulePath, 'package.json'));
41
+ const runtimeRequire = (id: string) => {
42
+ try {
43
+ return localRequire(id);
44
+ } catch {
45
+ return handoffRequire(id);
46
+ }
47
+ };
48
+ const evaluator = new Function('require', 'module', 'exports', '__filename', '__dirname', code);
49
+ evaluator(runtimeRequire, mod, mod.exports, filePath, path.dirname(filePath));
50
+ return mod.exports;
51
+ };
52
+
53
+ const loadConfigFile = (configPath: string): Config => {
54
+ if (configPath.endsWith('.json')) {
55
+ const buffer = fs.readFileSync(configPath);
56
+ return JSON.parse(buffer.toString()) as Config;
57
+ }
58
+
59
+ if (configPath.endsWith('.ts')) {
60
+ const handoffModulePath = path.resolve(__dirname, '../..');
61
+ const importedConfig = evaluateTypeScriptConfig(configPath, handoffModulePath);
62
+ return (importedConfig.default || importedConfig) as Config;
63
+ }
64
+
65
+ // Invalidate require cache to ensure fresh read
66
+ delete require.cache[require.resolve(configPath)];
67
+ const importedConfig = require(configPath);
68
+ return (importedConfig.default || importedConfig) as Config;
69
+ };
70
+
71
+ const resolveConfigFilePath = (): { selected?: string; ignored: string[] } => {
72
+ const existing = CONFIG_FILE_PREFERENCE
73
+ .map((fileName) => path.resolve(process.cwd(), fileName))
74
+ .filter((filePath) => fs.existsSync(filePath));
75
+
76
+ if (!existing.length) {
77
+ return { selected: undefined, ignored: [] };
78
+ }
79
+
80
+ const [selected, ...ignored] = existing;
81
+ return { selected, ignored };
82
+ };
83
+
6
84
  /**
7
- * Loads the handoff configuration from the project root.
8
- *
9
- * Searches for config files in order: handoff.config.json, handoff.config.js, handoff.config.cjs.
10
- * Merges file config with any provided overrides, then applies defaults for missing values.
11
- *
12
- * @param configOverride - Optional partial config to override file-loaded values.
13
- * @returns The fully resolved Config object.
85
+ * Loads the handoff configuration from the project root and returns metadata.
14
86
  */
15
- export const initConfig = (configOverride?: Partial<Config>): Config => {
16
- let config = {};
17
-
18
- const possibleConfigFiles = ['handoff.config.json', 'handoff.config.js', 'handoff.config.cjs'];
19
-
20
- // Find the first existing config file
21
- const configFile = possibleConfigFiles.find((file) => fs.existsSync(path.resolve(process.cwd(), file)));
22
-
23
- if (configFile) {
24
- const configPath = path.resolve(process.cwd(), configFile);
25
- if (configFile.endsWith('.json')) {
26
- const defBuffer = fs.readFileSync(configPath);
27
- config = JSON.parse(defBuffer.toString()) as Config;
28
- } else if (configFile.endsWith('.js') || configFile.endsWith('.cjs')) {
29
- // Invalidate require cache to ensure fresh read
30
- delete require.cache[require.resolve(configPath)];
31
- const importedConfig = require(configPath);
32
- config = importedConfig.default || importedConfig;
33
- }
87
+ export const initConfigWithMetadata = (configOverride?: Partial<Config>): ConfigLoadResult => {
88
+ let config: Partial<Config> = {};
89
+ const { selected: configPath, ignored } = resolveConfigFilePath();
90
+
91
+ if (ignored.length > 0 && configPath) {
92
+ Logger.warn(
93
+ `Multiple config files found. Using "${path.basename(configPath)}" and ignoring: ${ignored
94
+ .map((filePath) => `"${path.basename(filePath)}"`)
95
+ .join(', ')}.`
96
+ );
97
+ }
98
+
99
+ if (configPath) {
100
+ config = loadConfigFile(configPath);
34
101
  }
35
102
 
36
103
  // Apply overrides if provided
37
104
  if (configOverride) {
38
- Object.keys(configOverride).forEach((key) => {
39
- const value = configOverride[key as keyof Config];
105
+ (Object.entries(configOverride) as [keyof Config, Config[keyof Config]][]).forEach(([key, value]) => {
40
106
  if (value !== undefined) {
41
- config[key as keyof Config] = value;
107
+ (config as Record<string, unknown>)[key as string] = value;
42
108
  }
43
109
  });
44
110
  }
45
111
 
46
- const returnConfig = { ...defaultConfig(), ...config } as unknown as Config;
47
- return returnConfig;
112
+ const resolvedConfig = { ...defaultConfig(), ...config } as Config;
113
+ return { config: resolvedConfig, configPath };
114
+ };
115
+
116
+ /**
117
+ * Loads the handoff configuration from the project root.
118
+ *
119
+ * Searches for config files in order: handoff.config.ts, handoff.config.js, handoff.config.cjs, handoff.config.json.
120
+ * Merges file config with any provided overrides, then applies defaults for missing values.
121
+ *
122
+ * @param configOverride - Optional partial config to override file-loaded values.
123
+ * @returns The fully resolved Config object.
124
+ */
125
+ export const initConfig = (configOverride?: Partial<Config>): Config => {
126
+ return initConfigWithMetadata(configOverride).config;
48
127
  };