@hubspot/cms-component-library 0.1.0-alpha.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/README.md +3 -0
- package/cli/commands/customize.ts +145 -0
- package/cli/commands/help.ts +56 -0
- package/cli/commands/version.ts +12 -0
- package/cli/index.ts +42 -0
- package/cli/tests/commands.test.ts +128 -0
- package/cli/tests/get-file.test.ts +82 -0
- package/cli/tests/version-integration.test.ts +39 -0
- package/cli/utils/cli-metadata.ts +9 -0
- package/cli/utils/component-naming.ts +76 -0
- package/cli/utils/components.ts +74 -0
- package/cli/utils/file-operations.ts +158 -0
- package/cli/utils/logging.ts +13 -0
- package/cli/utils/prompts.ts +80 -0
- package/cli/utils/version.ts +33 -0
- package/components/componentLibrary/Button/index.module.scss +9 -0
- package/components/componentLibrary/Button/index.tsx +83 -0
- package/components/componentLibrary/Button/scaffolds/fields.tsx.template +70 -0
- package/components/componentLibrary/Button/scaffolds/index.ts.template +95 -0
- package/components/componentLibrary/Heading/index.module.scss +9 -0
- package/components/componentLibrary/Heading/index.tsx +34 -0
- package/components/componentLibrary/Heading/scaffolds/fields.tsx.template +62 -0
- package/components/componentLibrary/Heading/scaffolds/index.ts.template +46 -0
- package/components/componentLibrary/index.ts +1 -0
- package/components/componentLibrary/styles/_component-base.scss +246 -0
- package/components/componentLibrary/types/index.ts +308 -0
- package/components/componentLibrary/utils/chainApi/choiceFieldGenerator.tsx +64 -0
- package/components/componentLibrary/utils/chainApi/index.ts +115 -0
- package/components/componentLibrary/utils/chainApi/labelGenerator.ts +76 -0
- package/components/componentLibrary/utils/chainApi/stateManager.ts +178 -0
- package/components/componentLibrary/utils/classname.ts +40 -0
- package/components/componentLibrary/utils/createConditionalClasses.ts +44 -0
- package/components/componentLibrary/utils/createHsclComponent.tsx +167 -0
- package/components/componentLibrary/utils/propResolution/createCssVariables.ts +58 -0
- package/components/componentLibrary/utils/propResolution/propResolutionUtils.ts +113 -0
- package/components/componentLibrary/utils/storybook/standardArgs.ts +607 -0
- package/package.json +62 -0
package/README.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { multiselectPrompt, promptForNamingPrefix } from '../utils/prompts.js';
|
|
3
|
+
import { logError, logSuccess } from '../utils/logging.js';
|
|
4
|
+
import {
|
|
5
|
+
getAvailableComponents,
|
|
6
|
+
validateComponentExists,
|
|
7
|
+
findComponentName,
|
|
8
|
+
} from '../utils/components.js';
|
|
9
|
+
import { copyMultipleScaffolds } from '../utils/file-operations.js';
|
|
10
|
+
import { validateAndFixPrefix } from '../utils/component-naming.js';
|
|
11
|
+
|
|
12
|
+
const promptForPrefixAndFix = async (components: string[]): Promise<string> => {
|
|
13
|
+
const customName = await promptForNamingPrefix('', components.length);
|
|
14
|
+
return validateAndFixPrefix(customName);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const handleComponentCreationResults = (
|
|
18
|
+
results: {
|
|
19
|
+
successful: string[];
|
|
20
|
+
skipped: string[];
|
|
21
|
+
errors: { component: string; error: string }[];
|
|
22
|
+
},
|
|
23
|
+
componentPrefix: string
|
|
24
|
+
) => {
|
|
25
|
+
if (results.successful.length > 0) {
|
|
26
|
+
logSuccess('ā
Successfully created customized components:');
|
|
27
|
+
results.successful.forEach((component: string) => {
|
|
28
|
+
logSuccess(` š /components/hscl/${componentPrefix}${component}/`);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (results.skipped.length > 0) {
|
|
33
|
+
console.log('\nā ļø Skipped components:');
|
|
34
|
+
results.skipped.forEach((component: string) => {
|
|
35
|
+
console.log(
|
|
36
|
+
` š /components/hscl/${componentPrefix}${component}/ (already exists)`
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (results.errors.length > 0) {
|
|
42
|
+
logError('\nā Errors occurred:');
|
|
43
|
+
results.errors.forEach(({ component, error }) => {
|
|
44
|
+
logError(` ${componentPrefix}${component}: ${error}`);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const tryScaffoldComponents = async (
|
|
50
|
+
selectedComponents: string[],
|
|
51
|
+
componentPrefix: string
|
|
52
|
+
) => {
|
|
53
|
+
try {
|
|
54
|
+
const results = await copyMultipleScaffolds({
|
|
55
|
+
sourceComponents: selectedComponents,
|
|
56
|
+
prefix: componentPrefix,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
handleComponentCreationResults(results, componentPrefix);
|
|
60
|
+
} catch (error: unknown) {
|
|
61
|
+
logError(
|
|
62
|
+
`ā Unexpected error: ${
|
|
63
|
+
error instanceof Error ? error.message : String(error)
|
|
64
|
+
}`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export function addCustomizeCommand(program: Command): void {
|
|
70
|
+
program
|
|
71
|
+
.command('customize [components...]')
|
|
72
|
+
.description(
|
|
73
|
+
'Scaffolds a new customized component. Takes in any number of components to customize. Use --list to see all available components.'
|
|
74
|
+
)
|
|
75
|
+
.option('--list', 'List all available components')
|
|
76
|
+
.action(async (components: string[], options: { list?: boolean }) => {
|
|
77
|
+
const availableComponents = await getAvailableComponents();
|
|
78
|
+
const hasComponents = components.length > 0;
|
|
79
|
+
|
|
80
|
+
// Scenario: user passed in --list flag or ran with no arguments (`npx hscl customize`) - jump into full interactive mode
|
|
81
|
+
if (options.list || !hasComponents) {
|
|
82
|
+
const { value: selectedComponents } = await multiselectPrompt({
|
|
83
|
+
message: 'Which component(s) do you want to generate?',
|
|
84
|
+
choices: availableComponents,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// if no components selected, exit
|
|
88
|
+
if (!selectedComponents || selectedComponents.length === 0) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// prefix prompt
|
|
93
|
+
const componentPrefix = await promptForPrefixAndFix(selectedComponents);
|
|
94
|
+
|
|
95
|
+
// scaffold components
|
|
96
|
+
await tryScaffoldComponents(selectedComponents, componentPrefix);
|
|
97
|
+
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Scenario: source component(s) provided
|
|
102
|
+
if (hasComponents) {
|
|
103
|
+
let unknownComponentsCounter = 0;
|
|
104
|
+
const normalizedComponents: string[] = [];
|
|
105
|
+
|
|
106
|
+
for (const component of components) {
|
|
107
|
+
const componentExists = await validateComponentExists(component);
|
|
108
|
+
|
|
109
|
+
if (!componentExists) {
|
|
110
|
+
unknownComponentsCounter++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Get the correctly cased component name
|
|
115
|
+
const correctlyNamed = await findComponentName(
|
|
116
|
+
availableComponents,
|
|
117
|
+
component
|
|
118
|
+
);
|
|
119
|
+
if (correctlyNamed) {
|
|
120
|
+
normalizedComponents.push(correctlyNamed);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (unknownComponentsCounter > 0) {
|
|
125
|
+
// validateComponentExists() above logs specific components not found (plus suggestions)
|
|
126
|
+
logError(
|
|
127
|
+
`ā Please fix the invalid component name${
|
|
128
|
+
unknownComponentsCounter > 1 ? 's' : ''
|
|
129
|
+
}, and try again.\n`
|
|
130
|
+
);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// prefix prompt
|
|
135
|
+
const componentPrefix = await promptForPrefixAndFix(
|
|
136
|
+
normalizedComponents
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// scaffold components
|
|
140
|
+
await tryScaffoldComponents(normalizedComponents, componentPrefix);
|
|
141
|
+
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getVersion } from '../utils/version.js';
|
|
3
|
+
import { CLI_metadata } from '../utils/cli-metadata.js';
|
|
4
|
+
|
|
5
|
+
const showDescriptionAndVersionText = `${CLI_metadata.description}
|
|
6
|
+
Version: v${getVersion()}`;
|
|
7
|
+
|
|
8
|
+
const showAvailableCommands = `ā”ļø AVAILABLE COMMANDS
|
|
9
|
+
| version # Show version information
|
|
10
|
+
| help # Display detailed help information (including examples)
|
|
11
|
+
| customize # Scaffolds a new customized component`;
|
|
12
|
+
|
|
13
|
+
const showCommandExamples = `āļø EXAMPLES
|
|
14
|
+
| customize # Starts interactive mode
|
|
15
|
+
| customize --list # Starts interactive mode (same as passing in no arguments)
|
|
16
|
+
| customize Button Heading # Outputs Button and Heading components with customization`;
|
|
17
|
+
|
|
18
|
+
export function addHelpCommand(program: Command): void {
|
|
19
|
+
program
|
|
20
|
+
.command('help')
|
|
21
|
+
.description('Display help information')
|
|
22
|
+
.action(() => {
|
|
23
|
+
console.log(`${showDescriptionAndVersionText}
|
|
24
|
+
|
|
25
|
+
š PURPOSE
|
|
26
|
+
| A CLI tool for getting started quickly with the HubSpot
|
|
27
|
+
| CMS Component Library. With this tool, you can create
|
|
28
|
+
| new components, customize them, and build React modules
|
|
29
|
+
| using your new components.
|
|
30
|
+
|
|
|
31
|
+
| š” TIP: Run commands without arguments for interactive mode!
|
|
32
|
+
|
|
33
|
+
${showAvailableCommands}
|
|
34
|
+
|
|
35
|
+
${showCommandExamples}
|
|
36
|
+
|
|
37
|
+
š MORE RESOURCES
|
|
38
|
+
> HubSpot CMS React documentation:
|
|
39
|
+
https://developers.hubspot.com/docs/guides/cms/react/modules
|
|
40
|
+
`);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const outputQuickHelp = (unknownCommand: string | null): string => {
|
|
45
|
+
return `${
|
|
46
|
+
unknownCommand
|
|
47
|
+
? `ā ${unknownCommand} is an invalid command`
|
|
48
|
+
: 'ā ļø An argument is missing from your command'
|
|
49
|
+
}, but fret not!
|
|
50
|
+
ā¹ļø Here are some helpful commands and information to get you back on track:
|
|
51
|
+
|
|
52
|
+
${showDescriptionAndVersionText}
|
|
53
|
+
|
|
54
|
+
${showAvailableCommands}
|
|
55
|
+
`;
|
|
56
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import { getVersion } from '../utils/version.js';
|
|
3
|
+
import { CLI_metadata } from '../utils/cli-metadata.js';
|
|
4
|
+
|
|
5
|
+
export const addVersionCommand = (program: Command): void => {
|
|
6
|
+
program
|
|
7
|
+
.command('version')
|
|
8
|
+
.description('Show version number')
|
|
9
|
+
.action(() => {
|
|
10
|
+
console.log(`${CLI_metadata.description} v${getVersion()}`);
|
|
11
|
+
});
|
|
12
|
+
};
|
package/cli/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env npx tsx
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { getVersion } from './utils/version.js';
|
|
5
|
+
import { CLI_metadata } from './utils/cli-metadata.js';
|
|
6
|
+
import { addVersionCommand } from './commands/version.js';
|
|
7
|
+
import { addHelpCommand, outputQuickHelp } from './commands/help.js';
|
|
8
|
+
import { addCustomizeCommand } from './commands/customize.js';
|
|
9
|
+
|
|
10
|
+
const program: Command = new Command();
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name(CLI_metadata.name)
|
|
14
|
+
.description(CLI_metadata.description)
|
|
15
|
+
.version(getVersion());
|
|
16
|
+
|
|
17
|
+
addVersionCommand(program);
|
|
18
|
+
|
|
19
|
+
addHelpCommand(program);
|
|
20
|
+
|
|
21
|
+
addCustomizeCommand(program);
|
|
22
|
+
|
|
23
|
+
// Catch no arguments or invalid commands --> show quick help and exit so terminal is ready to receive new commands
|
|
24
|
+
|
|
25
|
+
// No arguments, exit normally
|
|
26
|
+
// (i.e. argv only contains node path and script path, e.g. "npx hscl")
|
|
27
|
+
if (process.argv.length === 2) {
|
|
28
|
+
const quickHelpText = outputQuickHelp(null);
|
|
29
|
+
console.log(quickHelpText);
|
|
30
|
+
process.exit(0); // exit code 0: success/normal termination, prevents Commander from adding more help text under our own
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Invalid commands, exit with error
|
|
34
|
+
// (wildcard catches all unknown commands)
|
|
35
|
+
// (e.g. "npx hscl fake_command")
|
|
36
|
+
program.on('command:*', unknownCommand => {
|
|
37
|
+
const quickHelpText = outputQuickHelp(unknownCommand);
|
|
38
|
+
console.log(quickHelpText);
|
|
39
|
+
program.error(''); // exit with error formatting and empty error message (being handled above with console.log of quickHelpText)
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
program.parse();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
it,
|
|
4
|
+
expect,
|
|
5
|
+
vi,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
type MockedFunction,
|
|
9
|
+
} from 'vitest';
|
|
10
|
+
import { readFileSync } from 'fs';
|
|
11
|
+
import { CLI_metadata } from '../utils/cli-metadata.js';
|
|
12
|
+
import type { Command } from 'commander';
|
|
13
|
+
|
|
14
|
+
// Mock fs for getVersion dependency
|
|
15
|
+
vi.mock('fs', () => ({
|
|
16
|
+
readFileSync: vi.fn(),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
describe('CLI Commands', () => {
|
|
20
|
+
let consoleSpy: MockedFunction<typeof console.log>;
|
|
21
|
+
let mockProgram: Pick<Command, 'command' | 'description' | 'action'> & {
|
|
22
|
+
command: MockedFunction<Command['command']>;
|
|
23
|
+
description: MockedFunction<Command['description']>;
|
|
24
|
+
action: MockedFunction<Command['action']>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
// Mock console.log to capture output
|
|
29
|
+
consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
30
|
+
|
|
31
|
+
// Mock Commander program
|
|
32
|
+
mockProgram = {
|
|
33
|
+
command: vi.fn().mockReturnThis(),
|
|
34
|
+
description: vi.fn().mockReturnThis(),
|
|
35
|
+
action: vi.fn().mockReturnThis(),
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// Setup mock package.json for getVersion
|
|
39
|
+
const mockPackageJson = JSON.stringify({
|
|
40
|
+
name: 'test-cli',
|
|
41
|
+
version: '1.0.0',
|
|
42
|
+
});
|
|
43
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockReturnValue(
|
|
44
|
+
mockPackageJson
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
vi.restoreAllMocks();
|
|
50
|
+
consoleSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe('version command', () => {
|
|
54
|
+
it('should register version command correctly', async () => {
|
|
55
|
+
const { addVersionCommand } = await import('../commands/version.ts');
|
|
56
|
+
|
|
57
|
+
addVersionCommand(mockProgram);
|
|
58
|
+
|
|
59
|
+
expect(mockProgram.command).toHaveBeenCalledWith('version');
|
|
60
|
+
expect(mockProgram.description).toHaveBeenCalledWith(
|
|
61
|
+
'Show version number'
|
|
62
|
+
);
|
|
63
|
+
expect(mockProgram.action).toHaveBeenCalledWith(expect.any(Function));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('should output correct version information when executed', async () => {
|
|
67
|
+
const { addVersionCommand } = await import('../commands/version.ts');
|
|
68
|
+
|
|
69
|
+
addVersionCommand(mockProgram);
|
|
70
|
+
|
|
71
|
+
// Get the action function and execute it
|
|
72
|
+
const actionFunction = mockProgram.action.mock.calls[0][0];
|
|
73
|
+
actionFunction();
|
|
74
|
+
|
|
75
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
76
|
+
expect.stringContaining(`${CLI_metadata.description} v`)
|
|
77
|
+
);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('help command', () => {
|
|
82
|
+
it('should register help command correctly', async () => {
|
|
83
|
+
const { addHelpCommand } = await import('../commands/help.ts');
|
|
84
|
+
|
|
85
|
+
addHelpCommand(mockProgram);
|
|
86
|
+
|
|
87
|
+
expect(mockProgram.command).toHaveBeenCalledWith('help');
|
|
88
|
+
expect(mockProgram.description).toHaveBeenCalledWith(
|
|
89
|
+
'Display help information'
|
|
90
|
+
);
|
|
91
|
+
expect(mockProgram.action).toHaveBeenCalledWith(expect.any(Function));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('should output comprehensive help information when executed', async () => {
|
|
95
|
+
const { addHelpCommand } = await import('../commands/help.ts');
|
|
96
|
+
|
|
97
|
+
addHelpCommand(mockProgram);
|
|
98
|
+
|
|
99
|
+
// Get the action function and execute it
|
|
100
|
+
const actionFunction = mockProgram.action.mock.calls[0][0];
|
|
101
|
+
actionFunction();
|
|
102
|
+
|
|
103
|
+
const output = consoleSpy.mock.calls[0][0];
|
|
104
|
+
|
|
105
|
+
// Verify key sections are present
|
|
106
|
+
expect(output).toContain(CLI_metadata.description);
|
|
107
|
+
expect(output).toContain('PURPOSE');
|
|
108
|
+
expect(output).toContain('COMMANDS');
|
|
109
|
+
expect(output).toContain('RESOURCES');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('help utility functions', () => {
|
|
114
|
+
it('should generate quick help output', async () => {
|
|
115
|
+
const { outputQuickHelp } = await import('../commands/help.ts');
|
|
116
|
+
|
|
117
|
+
const unknownCommandOutput = outputQuickHelp('invalidcmd');
|
|
118
|
+
const missingArgsOutput = outputQuickHelp();
|
|
119
|
+
|
|
120
|
+
expect(unknownCommandOutput).toContain(
|
|
121
|
+
'invalidcmd is an invalid command'
|
|
122
|
+
);
|
|
123
|
+
expect(missingArgsOutput).toContain('argument is missing');
|
|
124
|
+
expect(unknownCommandOutput).toContain(CLI_metadata.description);
|
|
125
|
+
expect(missingArgsOutput).toContain(CLI_metadata.description);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect, vi, type MockedFunction } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
import { getFileContents } from '../utils/file-operations.js';
|
|
4
|
+
|
|
5
|
+
// Mock fs
|
|
6
|
+
vi.mock('fs', () => ({
|
|
7
|
+
readFileSync: vi.fn(),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
describe('getFileContents', () => {
|
|
11
|
+
it('should read file contents successfully', () => {
|
|
12
|
+
const mockContent = 'file content';
|
|
13
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockReturnValue(
|
|
14
|
+
mockContent
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
// file name doesn't matter because we're mocking the readFileSync function above
|
|
18
|
+
const result = getFileContents('fakeFile.txt');
|
|
19
|
+
|
|
20
|
+
expect(result).toBe(mockContent);
|
|
21
|
+
expect(readFileSync).toHaveBeenCalledWith(
|
|
22
|
+
expect.stringContaining('fakeFile.txt'),
|
|
23
|
+
'utf8'
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// error cases
|
|
28
|
+
|
|
29
|
+
it('should throw error with descriptive message for missing file', () => {
|
|
30
|
+
const error = new Error('File not found') as NodeJS.ErrnoException;
|
|
31
|
+
error.code = 'ENOENT';
|
|
32
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockImplementation(
|
|
33
|
+
() => {
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
expect(() => getFileContents('missing.txt')).toThrow(/File not found/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should throw error with descriptive message for permission denied', () => {
|
|
42
|
+
const error = new Error('Permission denied') as NodeJS.ErrnoException;
|
|
43
|
+
error.code = 'EACCES';
|
|
44
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockImplementation(
|
|
45
|
+
() => {
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(() => getFileContents('restricted.txt')).toThrow(
|
|
51
|
+
/Permission denied/
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('should throw error with descriptive message when path is directory', () => {
|
|
56
|
+
const error = new Error('Is a directory') as NodeJS.ErrnoException;
|
|
57
|
+
error.code = 'EISDIR';
|
|
58
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockImplementation(
|
|
59
|
+
() => {
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(() => getFileContents('some-dir')).toThrow(/Is a directory/);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should throw generic error for unknown error codes', () => {
|
|
68
|
+
const error = new Error(
|
|
69
|
+
'Unknown filesystem error'
|
|
70
|
+
) as NodeJS.ErrnoException;
|
|
71
|
+
error.code = 'UNKNOWN';
|
|
72
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockImplementation(
|
|
73
|
+
() => {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
expect(() => getFileContents('file.txt')).toThrow(
|
|
79
|
+
/Unknown filesystem error/
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect, vi, type MockedFunction } from 'vitest';
|
|
2
|
+
import { readFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
// Mock fs at the module level so both files use the same mock
|
|
5
|
+
vi.mock('fs', () => ({
|
|
6
|
+
readFileSync: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
|
|
9
|
+
describe('getVersion integration with getFileContents', () => {
|
|
10
|
+
it('should successfully integrate getFileContents and JSON parsing', async () => {
|
|
11
|
+
const mockPackageJson = JSON.stringify({
|
|
12
|
+
name: 'test-package',
|
|
13
|
+
version: '1.2.3',
|
|
14
|
+
description: 'Test package',
|
|
15
|
+
dependencies: {
|
|
16
|
+
'some-package': '^1.0.0',
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
(readFileSync as MockedFunction<typeof readFileSync>).mockReturnValue(
|
|
21
|
+
mockPackageJson
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Import after mocking to ensure the mock is applied
|
|
25
|
+
const { getVersion } = await import('../utils/version.ts');
|
|
26
|
+
|
|
27
|
+
const version = getVersion();
|
|
28
|
+
|
|
29
|
+
// Verify the integration chain works:
|
|
30
|
+
// getVersion -> getFileContents -> readFileSync -> JSON.parse -> return version
|
|
31
|
+
expect(version).toBe('1.2.3');
|
|
32
|
+
|
|
33
|
+
// Verify that getFileContents was called with the correct relative path
|
|
34
|
+
expect(readFileSync).toHaveBeenCalledWith(
|
|
35
|
+
expect.stringContaining('package.json'),
|
|
36
|
+
'utf8'
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { logWarning } from '../utils/logging.js';
|
|
2
|
+
|
|
3
|
+
// Validation functions
|
|
4
|
+
export const validatePascalCase = (
|
|
5
|
+
value?: string,
|
|
6
|
+
fallbackValue: string | null = null
|
|
7
|
+
): string | true => {
|
|
8
|
+
const finalValue = value || fallbackValue;
|
|
9
|
+
if (!finalValue) return 'Component name is required';
|
|
10
|
+
if (!/^[A-Z][a-zA-Z0-9]*$/.test(finalValue)) {
|
|
11
|
+
return 'Component name must be PascalCase (e.g., MyComponent)';
|
|
12
|
+
}
|
|
13
|
+
return true;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const validateAndFixPrefix = (customName: string): string => {
|
|
17
|
+
let fixedName = customName;
|
|
18
|
+
|
|
19
|
+
// Convert to PascalCase: capitalize letters after spaces, hyphens, and underscores, then remove the separators
|
|
20
|
+
fixedName = fixedName.replace(/[ \-_]+(.)/g, (_, letter) =>
|
|
21
|
+
letter.toUpperCase()
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
// Fix lowercase first letter
|
|
25
|
+
if (fixedName[0]) {
|
|
26
|
+
fixedName = fixedName[0].toUpperCase() + fixedName.slice(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Show message if anything was changed
|
|
30
|
+
if (fixedName !== customName) {
|
|
31
|
+
logWarning(
|
|
32
|
+
`ā ļø Your custom name doesn't meet React naming standards. We fixed it for you: "${customName}" ā "${fixedName}"`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return fixedName;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// String similarity function using Levenshtein distance
|
|
40
|
+
export function calculateTextSimilarity(str1: string, str2: string): number {
|
|
41
|
+
const longer = str1.length > str2.length ? str1 : str2;
|
|
42
|
+
const shorter = str1.length > str2.length ? str2 : str1;
|
|
43
|
+
|
|
44
|
+
if (longer.length === 0) return 1.0;
|
|
45
|
+
|
|
46
|
+
const textDiff = getTextDifference(longer, shorter);
|
|
47
|
+
return (longer.length - textDiff) / longer.length;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getTextDifference(str1: string, str2: string): number {
|
|
51
|
+
const matrix: number[][] = [];
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i <= str2.length; i++) {
|
|
54
|
+
matrix[i] = [i];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
for (let j = 0; j <= str1.length; j++) {
|
|
58
|
+
matrix[0][j] = j;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (let i = 1; i <= str2.length; i++) {
|
|
62
|
+
for (let j = 1; j <= str1.length; j++) {
|
|
63
|
+
if (str2.charAt(i - 1) === str1.charAt(j - 1)) {
|
|
64
|
+
matrix[i][j] = matrix[i - 1][j - 1];
|
|
65
|
+
} else {
|
|
66
|
+
matrix[i][j] = Math.min(
|
|
67
|
+
matrix[i - 1][j - 1] + 1,
|
|
68
|
+
matrix[i][j - 1] + 1,
|
|
69
|
+
matrix[i - 1][j] + 1
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return matrix[str2.length][str1.length];
|
|
76
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { promises as fs } from 'fs';
|
|
2
|
+
import { fileURLToPath } from 'url';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import {
|
|
5
|
+
calculateTextSimilarity,
|
|
6
|
+
validatePascalCase,
|
|
7
|
+
} from './component-naming.js';
|
|
8
|
+
import { logError } from './logging.js';
|
|
9
|
+
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
export async function getAvailableComponents(): Promise<string[]> {
|
|
14
|
+
const componentsDir = path.join(
|
|
15
|
+
__dirname,
|
|
16
|
+
'../../components/componentLibrary'
|
|
17
|
+
);
|
|
18
|
+
const entries = await fs.readdir(componentsDir, { withFileTypes: true });
|
|
19
|
+
|
|
20
|
+
// only want to return components (ignoring util/type/etc directories)
|
|
21
|
+
return entries
|
|
22
|
+
.filter(entry => entry.isDirectory())
|
|
23
|
+
.filter(entry => validatePascalCase(entry.name) === true)
|
|
24
|
+
.map(entry => entry.name)
|
|
25
|
+
.sort();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function findComponentName(
|
|
29
|
+
components: string[],
|
|
30
|
+
sourceComponent: string
|
|
31
|
+
): Promise<string | null> {
|
|
32
|
+
// Find exact match first, then case-insensitive match
|
|
33
|
+
const exactMatch = components.find(
|
|
34
|
+
component => component === sourceComponent
|
|
35
|
+
);
|
|
36
|
+
const caseInsensitiveMatch = components.find(
|
|
37
|
+
component => component.toLowerCase() === sourceComponent.toLowerCase()
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
return exactMatch || caseInsensitiveMatch || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function validateComponentExists(
|
|
44
|
+
sourceComponent: string
|
|
45
|
+
): Promise<boolean> {
|
|
46
|
+
const components = await getAvailableComponents();
|
|
47
|
+
const foundComponent = await findComponentName(components, sourceComponent);
|
|
48
|
+
|
|
49
|
+
if (foundComponent) return true;
|
|
50
|
+
|
|
51
|
+
const componentNotFoundMessage = `ā Component "${sourceComponent}" not found.`;
|
|
52
|
+
|
|
53
|
+
const suggestions = components
|
|
54
|
+
.map(component => ({
|
|
55
|
+
name: component,
|
|
56
|
+
similarity: calculateTextSimilarity(
|
|
57
|
+
sourceComponent.toLowerCase(),
|
|
58
|
+
component.toLowerCase()
|
|
59
|
+
),
|
|
60
|
+
}))
|
|
61
|
+
.filter(item => item.similarity > 0.3) // Only show if 30% similar
|
|
62
|
+
.sort((a, b) => b.similarity - a.similarity)
|
|
63
|
+
.slice(0, 3) // Top 3 suggestions
|
|
64
|
+
.map(item => item.name);
|
|
65
|
+
|
|
66
|
+
if (suggestions.length > 0) {
|
|
67
|
+
logError(
|
|
68
|
+
`${componentNotFoundMessage} Did you mean: ${suggestions.join(', ')}?`
|
|
69
|
+
);
|
|
70
|
+
} else {
|
|
71
|
+
logError(`${componentNotFoundMessage}`);
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|