handoff-app 1.0.3 → 1.0.5

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 (55) hide show
  1. package/Changelog.md +16 -5
  2. package/README.md +93 -57
  3. package/dist/cli.js +1 -1
  4. package/dist/commands/index.d.ts +1 -1
  5. package/dist/commands/index.js +2 -0
  6. package/dist/commands/init/index.d.ts +5 -0
  7. package/dist/commands/init/index.js +26 -0
  8. package/dist/create.d.ts +5 -0
  9. package/dist/create.js +293 -0
  10. package/dist/index.js +4 -1
  11. package/dist/transformers/plugins/ssr-render.js +4 -3
  12. package/dist/transformers/preview/component/api.js +3 -2
  13. package/dist/transformers/preview/component/builder.js +2 -1
  14. package/dist/transformers/utils/handlebars.js +3 -2
  15. package/package.json +1 -1
  16. package/src/app/components/Component/Preview.tsx +121 -121
  17. package/src/app/components/ComponentSearch.tsx +1 -1
  18. package/src/app/components/util/index.ts +17 -9
  19. package/src/app/pages/system/component/[component]/index.tsx +1 -1
  20. package/src/app/pages/system/index.tsx +2 -2
  21. package/src/app/pages/system/tokens/index.tsx +10 -10
  22. package/src/cli.ts +1 -1
  23. package/src/commands/index.ts +2 -0
  24. package/src/commands/init/index.ts +17 -0
  25. package/src/create.ts +302 -0
  26. package/src/index.ts +3 -0
  27. package/src/transformers/plugins/ssr-render.ts +3 -3
  28. package/src/transformers/preview/component/api.ts +1 -1
  29. package/src/transformers/preview/component/builder.ts +1 -1
  30. package/src/transformers/utils/handlebars.ts +2 -2
  31. package/templates/default/README.md.tpl +15 -0
  32. package/templates/default/components/button/Button.tsx.tpl +92 -0
  33. package/templates/default/components/button/button.js.tpl +62 -0
  34. package/templates/default/env.tpl +2 -0
  35. package/templates/default/gitignore.tpl +7 -0
  36. package/templates/default/handoff.config.js.tpl +181 -0
  37. package/templates/default/handoff.config.with-examples.js.tpl +182 -0
  38. package/templates/default/package.json.tpl +20 -0
  39. package/templates/default/tsconfig.json.tpl +24 -0
  40. package/dist/commands/platform/init.d.ts +0 -6
  41. package/dist/commands/platform/init.js +0 -231
  42. package/dist/commands/platform/login.d.ts +0 -6
  43. package/dist/commands/platform/login.js +0 -108
  44. package/dist/commands/platform/logout.d.ts +0 -6
  45. package/dist/commands/platform/logout.js +0 -88
  46. package/dist/commands/platform/pull.d.ts +0 -8
  47. package/dist/commands/platform/pull.js +0 -112
  48. package/dist/commands/platform/push.d.ts +0 -8
  49. package/dist/commands/platform/push.js +0 -428
  50. package/dist/platform/client.d.ts +0 -122
  51. package/dist/platform/client.js +0 -228
  52. package/dist/platform/credentials.d.ts +0 -40
  53. package/dist/platform/credentials.js +0 -91
  54. package/dist/platform/sync.d.ts +0 -68
  55. package/dist/platform/sync.js +0 -349
package/src/create.ts ADDED
@@ -0,0 +1,302 @@
1
+ import * as p from '@clack/prompts';
2
+ import chalk from 'chalk';
3
+ import { spawn } from 'child_process';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+
7
+ /**
8
+ * Get the current handoff-app version from package.json
9
+ */
10
+ const getVersion = (): string => {
11
+ try {
12
+ const packageJsonPath = path.resolve(__dirname, '../package.json');
13
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
14
+ return packageJson.version || 'unknown';
15
+ } catch {
16
+ return 'unknown';
17
+ }
18
+ };
19
+
20
+ interface CreateOptions {
21
+ projectName: string;
22
+ figmaProjectId: string;
23
+ figmaAccessToken: string;
24
+ [key: string]: string; // Allow dynamic access for template replacement
25
+ }
26
+
27
+ class CreateError extends Error {
28
+ constructor(message: string) {
29
+ super(message);
30
+ this.name = 'CreateError';
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Check if directory exists
36
+ */
37
+ const directoryExists = (dirPath: string): boolean => {
38
+ try {
39
+ return fs.existsSync(dirPath);
40
+ } catch {
41
+ return false;
42
+ }
43
+ };
44
+
45
+ /**
46
+ * Create directory recursively
47
+ */
48
+ const createDirectory = (dirPath: string): void => {
49
+ try {
50
+ fs.mkdirSync(dirPath, { recursive: true });
51
+ } catch (error) {
52
+ throw new CreateError(`Failed to create directory: ${error}`);
53
+ }
54
+ };
55
+
56
+ interface TemplateConfig {
57
+ source: string;
58
+ destination: string;
59
+ isExample?: boolean; // Flag to indicate if this is an example file
60
+ }
61
+
62
+ /**
63
+ * Get mandatory templates based on whether examples are included
64
+ * Uses different handoff config templates for blank vs. with-examples projects
65
+ */
66
+ const getMandatoryTemplates = (includeExamples: boolean): TemplateConfig[] => [
67
+ { source: 'package.json.tpl', destination: 'package.json' },
68
+ {
69
+ source: includeExamples ? 'handoff.config.with-examples.js.tpl' : 'handoff.config.js.tpl',
70
+ destination: 'handoff.config.js'
71
+ },
72
+ { source: 'tsconfig.json.tpl', destination: 'tsconfig.json' },
73
+ { source: 'gitignore.tpl', destination: '.gitignore' },
74
+ { source: 'env.tpl', destination: '.env' },
75
+ { source: 'README.md.tpl', destination: 'README.md' },
76
+ ];
77
+
78
+ // Example component files (only included when user opts for examples)
79
+ const exampleTemplates: TemplateConfig[] = [
80
+ { source: 'components/button/Button.tsx.tpl', destination: 'components/button/Button.tsx', isExample: true },
81
+ { source: 'components/button/button.js.tpl', destination: 'components/button/button.js', isExample: true },
82
+ ];
83
+
84
+ /**
85
+ * Render a template string by replacing placeholders with values
86
+ */
87
+ const renderTemplate = (content: string, data: CreateOptions): string => {
88
+ return content.replace(/\{\{(\w+)\}\}/g, (_, key) => {
89
+ if (!(key in data)) {
90
+ console.warn(chalk.yellow(`Warning: Unknown template placeholder '{{${key}}}'`));
91
+ }
92
+ return data[key] || '';
93
+ });
94
+ };
95
+
96
+ /**
97
+ * Execute npm install
98
+ */
99
+ const runNpmInstall = async (projectPath: string): Promise<void> => {
100
+ const spinner = p.spinner();
101
+ spinner.start('Installing dependencies...');
102
+
103
+ return new Promise((resolve, reject) => {
104
+ const npmProcess = spawn('npm', ['install'], {
105
+ cwd: projectPath,
106
+ stdio: 'pipe',
107
+ shell: true,
108
+ });
109
+
110
+ let stderr = '';
111
+
112
+ npmProcess.stderr?.on('data', (data) => {
113
+ stderr += data.toString();
114
+ });
115
+
116
+ npmProcess.on('close', (code) => {
117
+ if (code === 0) {
118
+ spinner.stop('Dependencies installed successfully');
119
+ resolve();
120
+ } else {
121
+ spinner.stop('Failed to install dependencies');
122
+ reject(new CreateError(`npm install failed: ${stderr}`));
123
+ }
124
+ });
125
+
126
+ npmProcess.on('error', (error) => {
127
+ spinner.stop('Failed to install dependencies');
128
+ reject(new CreateError(`Failed to run npm install: ${error.message}`));
129
+ });
130
+ });
131
+ };
132
+
133
+ /**
134
+ * Validate project name
135
+ */
136
+ const validateProjectName = (name: string): boolean => {
137
+ const validNameRegex = /^[a-zA-Z0-9-_]+$/;
138
+ return validNameRegex.test(name) && name.length > 0;
139
+ };
140
+
141
+ /**
142
+ * Main create function
143
+ */
144
+ const create = async (): Promise<void> => {
145
+ const version = getVersion();
146
+ p.intro(chalk.bgCyan.black(` Handoff App Creator (v${version}) `));
147
+
148
+ try {
149
+ // Get project name
150
+ const projectName = await p.text({
151
+ message: 'Enter project name:',
152
+ placeholder: 'my-handoff-project',
153
+ validate: (value) => {
154
+ if (!value.trim()) return 'Project name is required';
155
+ if (!validateProjectName(value)) {
156
+ return 'Invalid project name. Use only letters, numbers, hyphens, and underscores.';
157
+ }
158
+ },
159
+ });
160
+
161
+ if (p.isCancel(projectName)) {
162
+ p.cancel('Project creation cancelled.');
163
+ process.exit(0);
164
+ }
165
+
166
+ const projectPath = path.resolve(process.cwd(), projectName as string);
167
+
168
+ // Check if directory exists
169
+ if (directoryExists(projectPath)) {
170
+ const overwrite = await p.confirm({
171
+ message: `Directory "${projectName}" already exists. Do you want to overwrite it?`,
172
+ initialValue: false,
173
+ });
174
+
175
+ if (p.isCancel(overwrite) || !overwrite) {
176
+ p.cancel('Project creation cancelled.');
177
+ process.exit(0);
178
+ }
179
+
180
+ // Remove existing directory
181
+ p.log.info('Removing existing directory...');
182
+ fs.rmSync(projectPath, { recursive: true, force: true });
183
+ }
184
+
185
+ // Create project directory
186
+ p.log.info('Creating project directory...');
187
+ createDirectory(projectPath);
188
+
189
+ // Ask if user wants a blank project or one with examples
190
+ p.log.step(chalk.blue('Project Configuration'));
191
+ const projectType = await p.select({
192
+ message: 'What type of project would you like to create?',
193
+ options: [
194
+ { value: 'with-examples', label: 'Project with sample components', hint: 'Includes example components to get started' },
195
+ { value: 'blank', label: 'Blank project', hint: 'Only mandatory files, no examples' },
196
+ ],
197
+ initialValue: 'with-examples',
198
+ });
199
+
200
+ if (p.isCancel(projectType)) {
201
+ p.cancel('Project creation cancelled.');
202
+ process.exit(0);
203
+ }
204
+
205
+ const includeExamples = projectType === 'with-examples';
206
+
207
+ // Get Figma project ID
208
+ p.log.step(chalk.blue('Figma Configuration'));
209
+ const figmaProjectId = await p.text({
210
+ message: 'Enter Figma project ID:',
211
+ placeholder: 'Optional - can be configured later',
212
+ });
213
+
214
+ if (p.isCancel(figmaProjectId)) {
215
+ p.cancel('Project creation cancelled.');
216
+ process.exit(0);
217
+ }
218
+
219
+ // Get Figma access token
220
+ let figmaAccessToken: string | boolean | symbol = '';
221
+
222
+ if (figmaProjectId) {
223
+ figmaAccessToken = await p.password({
224
+ message: 'Enter Figma developer access token:',
225
+ });
226
+
227
+ if (p.isCancel(figmaAccessToken)) {
228
+ p.cancel('Project creation cancelled.');
229
+ process.exit(0);
230
+ }
231
+ }
232
+
233
+ const options: CreateOptions = {
234
+ projectName: projectName as string,
235
+ figmaProjectId: ((figmaProjectId as string) || '').trim(),
236
+ figmaAccessToken: ((figmaAccessToken as string) || '').trim(),
237
+ };
238
+
239
+ // Create files
240
+ const spinner = p.spinner();
241
+ spinner.start('Creating project files...');
242
+
243
+ const templateDir = path.resolve(__dirname, '../templates/default');
244
+
245
+ if (!fs.existsSync(templateDir)) {
246
+ throw new CreateError(`Template directory not found at ${templateDir}. This may indicate a packaging issue.`);
247
+ }
248
+
249
+ // Combine mandatory templates with example templates based on user choice
250
+ const templates = [
251
+ ...getMandatoryTemplates(includeExamples),
252
+ ...(includeExamples ? exampleTemplates : []),
253
+ ];
254
+
255
+ try {
256
+ for (const template of templates) {
257
+ const templatePath = path.join(templateDir, template.source);
258
+ const destinationPath = path.join(projectPath, template.destination);
259
+
260
+ try {
261
+ const content = fs.readFileSync(templatePath, 'utf8');
262
+ const rendered = renderTemplate(content, options);
263
+ fs.mkdirSync(path.dirname(destinationPath), { recursive: true });
264
+ fs.writeFileSync(destinationPath, rendered);
265
+ } catch (err) {
266
+ throw new CreateError(`Failed to create file ${template.destination}: ${err}`);
267
+ }
268
+ }
269
+ spinner.stop('Project files created');
270
+ } catch (err) {
271
+ spinner.stop('Failed to create project files');
272
+ throw err;
273
+ }
274
+
275
+ // Install dependencies
276
+ await runNpmInstall(projectPath);
277
+
278
+ // Success message
279
+ const nextSteps = [`cd ${projectName}`];
280
+ if (figmaProjectId && figmaAccessToken) {
281
+ nextSteps.push('npm run fetch');
282
+ }
283
+ nextSteps.push('npm run start');
284
+
285
+ p.note(nextSteps.join('\n'), 'Next steps');
286
+
287
+ const outroMessage = includeExamples
288
+ ? 'Project created successfully with sample components! First, fetch your Figma data (optional), then start the documentation site.'
289
+ : 'Blank project created successfully! First, fetch your Figma data, then start the documentation site.';
290
+
291
+ p.outro(chalk.green(outroMessage));
292
+ } catch (error) {
293
+ if (error instanceof CreateError) {
294
+ p.cancel(chalk.red(`Error: ${error.message}`));
295
+ } else {
296
+ p.cancel(chalk.red(`Unexpected error: ${error}`));
297
+ }
298
+ process.exit(1);
299
+ }
300
+ };
301
+
302
+ export default create;
package/src/index.ts CHANGED
@@ -55,6 +55,9 @@ class Handoff {
55
55
  this.exportsDirectory = config.exportsOutputDirectory ?? this.exportsDirectory;
56
56
  this.sitesDirectory = config.sitesOutputDirectory ?? this.exportsDirectory;
57
57
  [this.runtimeConfig, this._configFilePaths] = initRuntimeConfig(this);
58
+ if(this.config.app.base_path && !process.env.HANDOFF_APP_BASE_PATH) {
59
+ process.env.HANDOFF_APP_BASE_PATH = this.config.app.base_path ?? '';
60
+ }
58
61
  return this;
59
62
  }
60
63
 
@@ -118,9 +118,9 @@ function generateHtmlDocument(componentId: string, previewTitle: string, rendere
118
118
  <html lang="en">
119
119
  <head>
120
120
  <meta charset="UTF-8" />
121
- <link rel="stylesheet" href="/api/component/main.css" />
122
- <link rel="stylesheet" href="/api/component/${componentId}.css" />
123
- <link rel="stylesheet" href="/assets/css/preview.css" />
121
+ <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/main.css" />
122
+ <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${componentId}.css" />
123
+ <link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/css/preview.css" />
124
124
  <script id="${PLUGIN_CONSTANTS.PROPS_SCRIPT_ID}" type="application/json">${JSON.stringify(props)}</script>
125
125
  <script type="module">
126
126
  ${clientJs}
@@ -107,7 +107,7 @@ export const updateComponentSummaryApi = async (handoff: Handoff, componentData:
107
107
  }
108
108
 
109
109
  // Partial update: merge with existing data
110
- const apiPath = path.resolve(handoff.workingPath, 'public/api/components.json');
110
+ const apiPath = path.resolve(handoff.workingPath, `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/components.json`);
111
111
  let existingData: ComponentListObject[] = [];
112
112
 
113
113
  if (fs.existsSync(apiPath)) {
@@ -363,7 +363,7 @@ const buildComponentSummary = (id: string, data: TransformComponentTokensResult)
363
363
  tags: data.tags ? data.tags : [],
364
364
  properties: data.properties,
365
365
  previews: data.previews,
366
- path: `/api/component/${id}.json`,
366
+ path: `${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${id}.json`,
367
367
  };
368
368
  };
369
369
 
@@ -60,8 +60,8 @@ export const createHandlebarsContext = (
60
60
  previewData: { values?: any }
61
61
  ): HandlebarsContext => {
62
62
  return {
63
- style: `<link rel="stylesheet" href="/api/component/shared.css"><link rel="stylesheet" href="/api/component/${data.id}.css">\n<link rel="stylesheet" href="/assets/css/preview.css">`,
64
- script: `<script src="/api/component/${data.id}.js"></script>\n<script src="/assets/js/preview.js"></script><script>var fields = ${JSON.stringify(data.properties)};</script>`,
63
+ style: `<link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/shared.css"><link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${data.id}.css">\n<link rel="stylesheet" href="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/css/preview.css">`,
64
+ script: `<script src="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/api/component/${data.id}.js"></script>\n<script src="${process.env.HANDOFF_APP_BASE_PATH ?? ''}/assets/js/preview.js"></script><script>var fields = ${JSON.stringify(data.properties)};</script>`,
65
65
  properties: previewData.values || {},
66
66
  fields: data.properties,
67
67
  title: data.title,
@@ -0,0 +1,15 @@
1
+ # {{projectName}}
2
+
3
+ A design system documentation site powered by Handoff App.
4
+
5
+ ## Getting Started
6
+
7
+ 1. Configure your Figma credentials in `.env`
8
+ 2. Run `npm run fetch` to pull design tokens
9
+ 3. Run `npm run start` to launch the documentation site
10
+
11
+ ## Scripts
12
+
13
+ - `npm run fetch` - Fetch design data from Figma
14
+ - `npm run start` - Start the documentation server
15
+ - `npm run build` - Build for production
@@ -0,0 +1,92 @@
1
+ import React, { useState } from 'react';
2
+
3
+ interface ButtonProps {
4
+ type?: 'primary' | 'secondary';
5
+ children: React.ReactNode;
6
+ disabled?: boolean;
7
+ className?: string;
8
+ showCounter?: boolean;
9
+ }
10
+
11
+ const Button: React.FC<ButtonProps> = ({
12
+ type = 'primary',
13
+ children,
14
+ disabled = false,
15
+ className = '',
16
+ showCounter = true
17
+ }) => {
18
+ const [clickCount, setClickCount] = useState(0);
19
+
20
+ const baseColor = type === 'primary' ? '#4f46e5' : '#dc2626';
21
+ const hoverColor = type === 'primary' ? '#4338ca' : '#b91c1c';
22
+
23
+ const handleClick = () => {
24
+ if (!disabled) {
25
+ setClickCount(prev => prev + 1);
26
+ }
27
+ };
28
+
29
+ const variantStyle: React.CSSProperties = {
30
+ backgroundColor: baseColor,
31
+ color: '#ffffff',
32
+ border: 'none',
33
+ borderRadius: '100px',
34
+ fontSize: '1rem',
35
+ padding: '0.625rem 1rem',
36
+ outline: 'none',
37
+ cursor: disabled ? 'not-allowed' : 'pointer',
38
+ opacity: disabled ? 0.6 : 1,
39
+ transition: 'all 0.15s ease-in-out',
40
+ fontWeight: 500,
41
+ display: 'inline-flex',
42
+ alignItems: 'center',
43
+ gap: '8px'
44
+ };
45
+
46
+ const wrapperStyle: React.CSSProperties = {
47
+ padding: '8px 0'
48
+ };
49
+
50
+ const counterBadgeStyle: React.CSSProperties = {
51
+ backgroundColor: 'rgba(255, 255, 255, 0.3)',
52
+ borderRadius: '12px',
53
+ padding: '2px 8px',
54
+ fontSize: '0.875rem',
55
+ fontWeight: 600,
56
+ minWidth: '24px',
57
+ textAlign: 'center'
58
+ };
59
+
60
+ return (
61
+ <div style={wrapperStyle}>
62
+ <button
63
+ type="button"
64
+ disabled={disabled}
65
+ style={variantStyle}
66
+ className={`btn btn-${type} ${className}`.trim()}
67
+ onClick={handleClick}
68
+ onMouseEnter={(e) => {
69
+ if (!disabled) {
70
+ e.currentTarget.style.backgroundColor = hoverColor;
71
+ e.currentTarget.style.transform = 'translateY(-1px)';
72
+ }
73
+ }}
74
+ onMouseLeave={(e) => {
75
+ if (!disabled) {
76
+ e.currentTarget.style.backgroundColor = baseColor;
77
+ e.currentTarget.style.transform = 'translateY(0)';
78
+ }
79
+ }}
80
+ >
81
+ <span>{children}</span>
82
+ {showCounter && (
83
+ <span style={counterBadgeStyle}>
84
+ {clickCount}
85
+ </span>
86
+ )}
87
+ </button>
88
+ </div>
89
+ );
90
+ };
91
+
92
+ export default Button;
@@ -0,0 +1,62 @@
1
+ /** @type {import('handoff-app').Component} */
2
+ module.exports = {
3
+ title: "Button",
4
+ description: "Interactive buttons that trigger actions and allow users to complete tasks, such as submitting a form or confirming a choice. This component includes click tracking to demonstrate stateful component behavior.",
5
+ group: "Atomic Elements",
6
+ type: "element",
7
+ categories: [
8
+ "functionality",
9
+ "interactive"
10
+ ],
11
+ entries: {
12
+ template: "Button.tsx",
13
+ // schema: "Schema.ts"
14
+ },
15
+ // Intentionally omit figmaComponentId so docs variants are not sourced from Figma.
16
+ previews: {
17
+ primary: {
18
+ title: "Primary Button",
19
+ values: {
20
+ type: "primary",
21
+ children: "Click me!",
22
+ showCounter: true
23
+ }
24
+ },
25
+ secondary: {
26
+ title: "Secondary Button",
27
+ values: {
28
+ type: "secondary",
29
+ children: "Click me too!",
30
+ showCounter: true
31
+ }
32
+ },
33
+ primaryDisabled: {
34
+ title: "Disabled State",
35
+ values: {
36
+ type: "primary",
37
+ children: "Disabled",
38
+ disabled: true,
39
+ showCounter: true
40
+ }
41
+ },
42
+ withoutCounter: {
43
+ title: "Without Counter",
44
+ values: {
45
+ type: "primary",
46
+ children: "Submit",
47
+ showCounter: false
48
+ }
49
+ }
50
+ },
51
+ should_do: [
52
+ "Keep one primary button per section so main action is clear.",
53
+ "Use the secondary variant for less important actions.",
54
+ "Provide clear, action-oriented labels that describe what will happen.",
55
+ "Show visual feedback on hover and click interactions."
56
+ ],
57
+ should_not_do: [
58
+ "Do not use vague labels like Click here or OK when the action can be specific.",
59
+ "Do not mix primary and secondary styles inconsistently.",
60
+ "Do not disable buttons without providing alternative guidance or feedback."
61
+ ]
62
+ };
@@ -0,0 +1,2 @@
1
+ HANDOFF_FIGMA_PROJECT_ID={{figmaProjectId}}
2
+ HANDOFF_DEV_ACCESS_TOKEN={{figmaAccessToken}}
@@ -0,0 +1,7 @@
1
+ node_modules/
2
+ .env
3
+ dist/
4
+ .next/
5
+ out/
6
+ .DS_Store
7
+ *.log