@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.
Files changed (37) hide show
  1. package/README.md +3 -0
  2. package/cli/commands/customize.ts +145 -0
  3. package/cli/commands/help.ts +56 -0
  4. package/cli/commands/version.ts +12 -0
  5. package/cli/index.ts +42 -0
  6. package/cli/tests/commands.test.ts +128 -0
  7. package/cli/tests/get-file.test.ts +82 -0
  8. package/cli/tests/version-integration.test.ts +39 -0
  9. package/cli/utils/cli-metadata.ts +9 -0
  10. package/cli/utils/component-naming.ts +76 -0
  11. package/cli/utils/components.ts +74 -0
  12. package/cli/utils/file-operations.ts +158 -0
  13. package/cli/utils/logging.ts +13 -0
  14. package/cli/utils/prompts.ts +80 -0
  15. package/cli/utils/version.ts +33 -0
  16. package/components/componentLibrary/Button/index.module.scss +9 -0
  17. package/components/componentLibrary/Button/index.tsx +83 -0
  18. package/components/componentLibrary/Button/scaffolds/fields.tsx.template +70 -0
  19. package/components/componentLibrary/Button/scaffolds/index.ts.template +95 -0
  20. package/components/componentLibrary/Heading/index.module.scss +9 -0
  21. package/components/componentLibrary/Heading/index.tsx +34 -0
  22. package/components/componentLibrary/Heading/scaffolds/fields.tsx.template +62 -0
  23. package/components/componentLibrary/Heading/scaffolds/index.ts.template +46 -0
  24. package/components/componentLibrary/index.ts +1 -0
  25. package/components/componentLibrary/styles/_component-base.scss +246 -0
  26. package/components/componentLibrary/types/index.ts +308 -0
  27. package/components/componentLibrary/utils/chainApi/choiceFieldGenerator.tsx +64 -0
  28. package/components/componentLibrary/utils/chainApi/index.ts +115 -0
  29. package/components/componentLibrary/utils/chainApi/labelGenerator.ts +76 -0
  30. package/components/componentLibrary/utils/chainApi/stateManager.ts +178 -0
  31. package/components/componentLibrary/utils/classname.ts +40 -0
  32. package/components/componentLibrary/utils/createConditionalClasses.ts +44 -0
  33. package/components/componentLibrary/utils/createHsclComponent.tsx +167 -0
  34. package/components/componentLibrary/utils/propResolution/createCssVariables.ts +58 -0
  35. package/components/componentLibrary/utils/propResolution/propResolutionUtils.ts +113 -0
  36. package/components/componentLibrary/utils/storybook/standardArgs.ts +607 -0
  37. package/package.json +62 -0
package/README.md ADDED
@@ -0,0 +1,3 @@
1
+ # HubSpot CMS React Component Library
2
+
3
+ This is a WIP and is actively under construction. Breaking changes are expected.
@@ -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,9 @@
1
+ export interface CLIMetadata {
2
+ name: string;
3
+ description: string;
4
+ }
5
+
6
+ export const CLI_metadata: CLIMetadata = {
7
+ name: 'hscl',
8
+ description: 'HubSpot CMS Component Library CLI',
9
+ };
@@ -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
+ }