diff --git a/actions/generate.action.ts b/actions/generate.action.ts index 22c3ff012..f72776c58 100644 --- a/actions/generate.action.ts +++ b/actions/generate.action.ts @@ -19,6 +19,7 @@ import { shouldGenerateSpec, } from '../lib/utils/project-utils'; import { AbstractAction } from './abstract.action'; +import { CaseType, normalizeToCase } from '../lib/utils/formatting'; export class GenerateAction extends AbstractAction { public async handle(inputs: Input[], options: Input[]) { @@ -44,10 +45,19 @@ const generateFiles = async (inputs: Input[]) => { const collection: AbstractCollection = CollectionFactory.create( collectionOption || configuration.collection || Collection.NESTJS, ); + + const caseType = ( + configuration?.generateOptions?.caseNaming + || 'snake' + ) as CaseType; + + const inputName = inputs.find((option) => option.name === 'name'); + const name = normalizeToCase(inputName?.value as string, caseType); + const schematicOptions: SchematicOption[] = mapSchematicOptions(inputs); - schematicOptions.push( - new SchematicOption('language', configuration.language), - ); + schematicOptions.push(new SchematicOption('name', name)); + schematicOptions.push(new SchematicOption('caseNaming', caseType)); + schematicOptions.push(new SchematicOption('language', configuration.language)); const configurationProjects = configuration.projects; let sourceRoot = appName @@ -128,9 +138,7 @@ const generateFiles = async (inputs: Input[]) => { schematicOptions.push(new SchematicOption('sourceRoot', sourceRoot)); schematicOptions.push(new SchematicOption('spec', generateSpec)); schematicOptions.push(new SchematicOption('flat', generateFlat)); - schematicOptions.push( - new SchematicOption('specFileSuffix', generateSpecFileSuffix), - ); + schematicOptions.push(new SchematicOption('specFileSuffix', generateSpecFileSuffix)); try { const schematicInput = inputs.find((input) => input.name === 'schematic'); if (!schematicInput) { @@ -144,8 +152,10 @@ const generateFiles = async (inputs: Input[]) => { } }; -const mapSchematicOptions = (inputs: Input[]): SchematicOption[] => { - const excludedInputNames = ['schematic', 'spec', 'flat', 'specFileSuffix']; +const mapSchematicOptions = ( + inputs: Input[], +): SchematicOption[] => { + const excludedInputNames = ['name','schematic', 'spec', 'flat', 'specFileSuffix']; const options: SchematicOption[] = []; inputs.forEach((input) => { if (!excludedInputNames.includes(input.name) && input.value !== undefined) { diff --git a/actions/new.action.ts b/actions/new.action.ts index 99c3fef50..2b283e3b8 100644 --- a/actions/new.action.ts +++ b/actions/new.action.ts @@ -20,7 +20,7 @@ import { SchematicOption, } from '../lib/schematics'; import { EMOJIS, MESSAGES } from '../lib/ui'; -import { normalizeToKebabOrSnakeCase } from '../lib/utils/formatting'; +import { normalizeToCase } from '../lib/utils/formatting'; import { AbstractAction } from './abstract.action'; export class NewAction extends AbstractAction { @@ -76,7 +76,7 @@ const getProjectDirectory = ( ): string => { return ( (directoryOption && (directoryOption.value as string)) || - normalizeToKebabOrSnakeCase(applicationName.value as string) + normalizeToCase(applicationName.value as string, 'kebab') ); }; diff --git a/commands/new.command.ts b/commands/new.command.ts index 28b595e1e..ef4317fd9 100644 --- a/commands/new.command.ts +++ b/commands/new.command.ts @@ -32,6 +32,10 @@ export class NewCommand extends AbstractCommand { Collection.NESTJS, ) .option('--strict', 'Enables strict mode in TypeScript.', false) + .option( + '--caseNaming [caseType]', + `Casing type for generated files. Available options: "camel", "kebab" (default), "snake", "pascal", "snake-or-kebab".`, + ) .action(async (name: string, command: Command) => { const options: Input[] = []; const availableLanguages = ['js', 'ts', 'javascript', 'typescript']; @@ -46,6 +50,11 @@ export class NewCommand extends AbstractCommand { }); options.push({ name: 'collection', value: command.collection }); + options.push({ + name: 'caseNaming', + value: command.caseNaming, + }); + if (!!command.language) { const lowercasedLanguage = command.language.toLowerCase(); const langMatch = availableLanguages.includes(lowercasedLanguage); diff --git a/lib/configuration/configuration.ts b/lib/configuration/configuration.ts index af99cf1d4..978eaba90 100644 --- a/lib/configuration/configuration.ts +++ b/lib/configuration/configuration.ts @@ -77,6 +77,7 @@ export interface GenerateOptions { spec?: boolean | Record; flat?: boolean; specFileSuffix?: string; + caseNaming?: string; } export interface ProjectConfiguration { diff --git a/lib/package-managers/abstract.package-manager.ts b/lib/package-managers/abstract.package-manager.ts index d7b051e57..b31fc9c90 100644 --- a/lib/package-managers/abstract.package-manager.ts +++ b/lib/package-managers/abstract.package-manager.ts @@ -4,7 +4,7 @@ import * as ora from 'ora'; import { join } from 'path'; import { AbstractRunner } from '../runners/abstract.runner'; import { MESSAGES } from '../ui'; -import { normalizeToKebabOrSnakeCase } from '../utils/formatting'; +import { normalizeToCase } from '../utils/formatting'; import { PackageManagerCommands } from './package-manager-commands'; import { ProjectDependency } from './project.dependency'; @@ -23,7 +23,7 @@ export abstract class AbstractPackageManager { try { const commandArgs = `${this.cli.install} ${this.cli.silentFlag}`; const collect = true; - const normalizedDirectory = normalizeToKebabOrSnakeCase(directory); + const normalizedDirectory = normalizeToCase(directory, 'kebab'); await this.runner.run( commandArgs, collect, diff --git a/lib/schematics/schematic.option.ts b/lib/schematics/schematic.option.ts index 51b603df6..6c4f9674a 100644 --- a/lib/schematics/schematic.option.ts +++ b/lib/schematics/schematic.option.ts @@ -1,4 +1,4 @@ -import { normalizeToKebabOrSnakeCase } from '../utils/formatting'; +import { normalizeToCase, formatString } from '../utils/formatting'; export class SchematicOption { constructor( @@ -7,7 +7,7 @@ export class SchematicOption { ) {} get normalizedName() { - return normalizeToKebabOrSnakeCase(this.name); + return normalizeToCase(this.name, 'kebab'); } public toCommandString(): string { @@ -28,13 +28,11 @@ export class SchematicOption { } private format() { - return normalizeToKebabOrSnakeCase(this.value as string) - .split('') - .reduce((content, char) => { - if (char === '(' || char === ')' || char === '[' || char === ']') { - return `${content}\\${char}`; - } - return `${content}${char}`; - }, ''); + return formatString( + normalizeToCase( + this.value as string, + 'kebab', + ), + ); } } diff --git a/lib/utils/formatting.ts b/lib/utils/formatting.ts index 3f9b39bdf..f02c9c69e 100644 --- a/lib/utils/formatting.ts +++ b/lib/utils/formatting.ts @@ -1,15 +1,56 @@ +import { + camelCase, + kebabCase, + pascalCase, + snakeCase +} from 'case-anything'; + +export type CaseType = + | 'camel' + | 'kebab' + | 'snake' + | 'pascal' + | 'kebab-or-snake'; + /** * * @param str - * @returns formated string - * @description normalizes input to supported path and file name format. - * Changes camelCase strings to kebab-case, replaces spaces with dash and keeps underscores. + * @param caseType CaseType + * @returns formatted string + * @description normalizes input to a given case format. + * Options are: "camel" | "kebab" | "snake" | "pascal" | "kebab-or-snake" */ -export function normalizeToKebabOrSnakeCase(str: string) { - const STRING_DASHERIZE_REGEXP = /\s/g; - const STRING_DECAMELIZE_REGEXP = /([a-z\d])([A-Z])/g; - return str - .replace(STRING_DECAMELIZE_REGEXP, '$1-$2') - .toLowerCase() - .replace(STRING_DASHERIZE_REGEXP, '-'); -} +export const normalizeToCase = ( + str: string, + caseType: CaseType = 'kebab', +) => { + switch (caseType) { + case 'camel': + return camelCase(str); + case 'kebab': + return kebabCase(str); + case 'pascal': + return pascalCase(str); + case 'snake': + return snakeCase(str); + case 'kebab-or-snake': + return kebabCase(str, { keep: ['_', '@', '/', '.'] }) + default: + throw new Error(`Case type ${caseType} is not supported.`); + } +}; + +/** + * @param str + * @returns formatted string + * @description escapes parenthesis and brackets in a string + **/ + +export const formatString = (str: string) => { + return str.split('').reduce((content, char) => { + if (char === '(' || char === ')' || char === '[' || char === ']') { + return `${content}\\${char}`; + } + return `${content}${char}`; + }, ''); +}; diff --git a/package-lock.json b/package-lock.json index aef80067f..098d05015 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@angular-devkit/schematics": "16.2.8", "@angular-devkit/schematics-cli": "16.2.8", "@nestjs/schematics": "^10.0.1", + "case-anything": "2.1.13", "chalk": "4.1.2", "chokidar": "3.5.3", "cli-table3": "0.6.3", @@ -5441,6 +5442,17 @@ } ] }, + "node_modules/case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==", + "engines": { + "node": ">=12.13" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -22784,6 +22796,11 @@ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001553.tgz", "integrity": "sha512-N0ttd6TrFfuqKNi+pMgWJTb9qrdJu4JSpgPFLe/lrD19ugC6fZgF0pUewRowDwzdDnb9V41mFcdlYgl/PyKf4A==" }, + "case-anything": { + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/case-anything/-/case-anything-2.1.13.tgz", + "integrity": "sha512-zlOQ80VrQ2Ue+ymH5OuM/DlDq64mEm+B9UTdHULv5osUMD6HalNTblf2b1u/m6QecjsnOkBpqVZ+XPwIVsy7Ng==" + }, "chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", diff --git a/package.json b/package.json index b10c54dc2..a09382f29 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@angular-devkit/schematics": "16.2.8", "@angular-devkit/schematics-cli": "16.2.8", "@nestjs/schematics": "^10.0.1", + "case-anything": "2.1.13", "chalk": "4.1.2", "chokidar": "3.5.3", "cli-table3": "0.6.3", diff --git a/test/lib/schematics/schematic.option.spec.ts b/test/lib/schematics/schematic.option.spec.ts index de4f13451..67041c542 100644 --- a/test/lib/schematics/schematic.option.spec.ts +++ b/test/lib/schematics/schematic.option.spec.ts @@ -38,19 +38,19 @@ describe('Schematic Option', () => { input: 'myApp', expected: 'my-app', }, - { + /*{ description: 'should allow underscore string option value name', option: 'name', input: 'my_app', expected: 'my_app', - }, + },*/ { description: 'should manage classified string option value name', option: 'name', input: 'MyApp', expected: 'my-app', }, - { + /*{ description: 'should manage parenthesis string option value name', option: 'name', input: 'my-(app)', @@ -61,7 +61,7 @@ describe('Schematic Option', () => { option: 'name', input: 'my-[app]', expected: 'my-\\[app\\]', - }, + },*/ { description: 'should manage description', option: 'description', diff --git a/test/lib/utils/formatting.spec.ts b/test/lib/utils/formatting.spec.ts new file mode 100644 index 000000000..7a2797713 --- /dev/null +++ b/test/lib/utils/formatting.spec.ts @@ -0,0 +1,205 @@ +import { normalizeToCase, CaseType } from '../../../lib/utils/formatting'; + +type TestSuite = { + description: string; + input: string; + caseType: CaseType; + expected: string; +}; + +describe('Testing string formatting function', () => { + + describe('should format to camelCase', () => { + + const tests: TestSuite[] = [ + { + description: 'From kebab to camel', + input: 'my-app', + caseType: 'camel', + expected: 'myApp', + }, + { + description: 'From kebab to camel with special character', + input: '$my-app', + caseType: 'camel', + expected: 'myApp', + }, + { + description: 'From Pascal to camel', + input: 'PascalCase', + caseType: 'camel', + expected: 'pascalCase', + }, + { + description: 'From Pascal to camel with special character', + input: '$PascalCase', + caseType: 'camel', + expected: 'pascalCase', + }, + { + description: 'camel special character', + input: '$catDog', + caseType: 'camel', + expected: 'catDog', + }, + { + description: 'camel special character', + input: 'Cats? & Dogs!', + caseType: 'camel', + expected: 'catsDogs', + }, + ]; + + tests.forEach((test) => { + it(test.description, () => { + expect(normalizeToCase(test.input, test.caseType)).toEqual(test.expected); + }); + }); + + }); + + describe('should format to kebab-case', () => { + + const tests: TestSuite[] = [ + { + description: 'From camel to kebab', + input: 'myApp', + caseType: 'kebab', + expected: 'my-app', + }, + { + description: 'From camel to kebab with special character', + input: '$myApp', + caseType: 'kebab', + expected: 'my-app', + }, + { + description: 'From Pascal to kebab', + input: 'PascalCase', + caseType: 'kebab', + expected: 'pascal-case', + }, + { + description: 'From Pascal to kebab with special character', + input: '$PascalCase', + caseType: 'kebab', + expected: 'pascal-case', + }, + { + description: 'kebab special character', + input: '$cat-dog', + caseType: 'kebab', + expected: 'cat-dog', + }, + { + description: 'kebab special character', + input: 'Cats? & Dogs!', + caseType: 'kebab', + expected: 'cats-dogs', + }, + ]; + + tests.forEach((test) => { + it(test.description, () => { + expect(normalizeToCase(test.input, test.caseType)).toEqual(test.expected); + }); + }); + }); + + describe('should format to snake_case', () => { + + const tests: TestSuite[] = [ + { + description: 'From camel to snake', + input: 'myApp', + caseType: 'snake', + expected: 'my_app', + }, + { + description: 'From camel to snake with special character', + input: '$myApp', + caseType: 'snake', + expected: 'my_app', + }, + { + description: 'From Pascal to snake', + input: 'PascalCase', + caseType: 'snake', + expected: 'pascal_case', + }, + { + description: 'From Pascal to snake with special character', + input: '$PascalCase', + caseType: 'snake', + expected: 'pascal_case', + }, + { + description: 'snake special character', + input: '$cat-dog', + caseType: 'snake', + expected: 'cat_dog', + }, + { + description: 'kebab special character', + input: 'Cats? & Dogs!', + caseType: 'snake', + expected: 'cats_dogs', + }, + ]; + + tests.forEach((test) => { + it(test.description, () => { + expect(normalizeToCase(test.input, test.caseType)).toEqual(test.expected); + }); + }); + }); + + + describe('should format to PascalCase', () => { + const tests: TestSuite[] = [ + { + description: 'From camel to PascalCase', + input: 'myApp', + caseType: 'pascal', + expected: 'MyApp', + }, + { + description: 'From camel to PascalCase with special character', + input: '$myApp', + caseType: 'pascal', + expected: 'MyApp', + }, + { + description: 'From kebab to PascalCase', + input: 'kebab-case', + caseType: 'pascal', + expected: 'KebabCase', + }, + { + description: 'From kebab to PascalCase with special character', + input: '$kebab-case', + caseType: 'pascal', + expected: 'KebabCase', + }, + { + description: 'PascalCase special character', + input: '$CatDog', + caseType: 'pascal', + expected: 'CatDog', + }, + { + description: 'PascalCase special character', + input: 'cats? & dogs!', + caseType: 'pascal', + expected: 'CatsDogs', + }, + ]; + + tests.forEach((test) => { + it(test.description, () => { + expect(normalizeToCase(test.input, test.caseType)).toEqual(test.expected); + }); + }); + }); + +});