diff --git a/.vscode/settings.json b/.vscode/settings.json index df36bac..0b14051 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,23 +1,21 @@ { - "cSpell.ignoreWords": [ - "Expresso" - ], - "workbench.colorCustomizations": { - // "activityBar.foreground": "#38e715", - // "activityBarBadge.background": "#248d0f", - // "activityBarBadge.foreground": "#fcfcfc", - // "sideBar.border": "#38e715", - // "sideBarTitle.foreground": "#38e715", - // "sideBarSectionHeader.border": "#38e715", - // "editorGroupHeader.border": "#38e715", - // "editorGroupHeader.tabsBorder": "#38e715", - // "tab.border": "#38e715", - // "tab.activeBorderTop": "#38e715", - // "panel.border": "#38e715", - // "statusBar.border": "#38e715", - // "statusBar.foreground": "#38e715" - }, - "editor.rulers": [ - 120 - ], -} \ No newline at end of file + "cSpell.ignoreWords": ["Expresso"], + "workbench.colorCustomizations": { + // "activityBar.foreground": "#38e715", + // "activityBarBadge.background": "#248d0f", + // "activityBarBadge.foreground": "#fcfcfc", + // "sideBar.border": "#38e715", + // "sideBarTitle.foreground": "#38e715", + // "sideBarSectionHeader.border": "#38e715", + // "editorGroupHeader.border": "#38e715", + // "editorGroupHeader.tabsBorder": "#38e715", + // "tab.border": "#38e715", + // "tab.activeBorderTop": "#38e715", + // "panel.border": "#38e715", + // "statusBar.border": "#38e715", + // "statusBar.foreground": "#38e715" + }, + "editor.rulers": [120], + "cSpell.words": ["nonopinionated", "usecase"], + "typescript.tsdk": "node_modules\\typescript\\lib" +} diff --git a/expressots.config.ts b/expressots.config.ts index 4d77fb1..0fbbcfe 100644 --- a/expressots.config.ts +++ b/expressots.config.ts @@ -1,9 +1,18 @@ import { ExpressoConfig, Pattern } from "./src/types"; const config: ExpressoConfig = { - sourceRoot: "src", - scaffoldPattern: Pattern.KEBAB_CASE, - opinionated: false + sourceRoot: "src", + scaffoldPattern: Pattern.KEBAB_CASE, + opinionated: true, + scaffoldSchematics: { + entity: "model", + provider: "adapter", + controller: "controller", + usecase: "operation", + dto: "payload", + module: "group", + middleware: "exjs", + }, }; export default config; diff --git a/package.json b/package.json index 55d7bbe..6ac121f 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "build": "npm run clean && tsc -p tsconfig.json && yarn cp:templates && chmod +x ./bin/cli.js", "cp:templates": "cp -r ./src/generate/templates ./bin/generate/templates && cp -r ./src/providers/prisma/templates ./bin/providers/prisma/templates", "clean": "rimraf ./bin", + "prepublish": "npm run build && npm pack", "format": "prettier --write \"./src/**/*.ts\" --cache", "lint": "eslint \"./src/**/*.ts\"", "lint:fix": "eslint \"./src/**/*.ts\" --fix", @@ -50,6 +51,7 @@ "@expressots/boost-ts": "1.1.1", "chalk-animation": "2.0.3", "cli-progress": "3.11.2", + "cli-table3": "^0.6.4", "degit": "2.8.4", "glob": "10.2.6", "inquirer": "8.0.0", diff --git a/src/@types/config.ts b/src/@types/config.ts index 14da83b..9d788c4 100644 --- a/src/@types/config.ts +++ b/src/@types/config.ts @@ -28,4 +28,13 @@ export interface ExpressoConfig { sourceRoot: string; opinionated: boolean; providers?: IProviders; + scaffoldSchematics?: { + entity?: string; + controller?: string; + usecase?: string; + dto?: string; + module?: string; + provider?: string; + middleware?: string; + }; } diff --git a/src/cli.ts b/src/cli.ts index cfaf9eb..8873c20 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,12 +4,11 @@ import yargs from "yargs"; import { hideBin } from "yargs/helpers"; import { runCommandModule } from "./commands/project.commands"; import { generateProject } from "./generate"; +import { helpCommand } from "./help/cli"; import { infoProject } from "./info"; import { createProject } from "./new"; import { generateProviders } from "./providers"; -export const CLI_VERSION = "1.3.4"; - console.log(`\n[šŸŽ Expressots]\n`); yargs(hideBin(process.argv)) @@ -19,6 +18,7 @@ yargs(hideBin(process.argv)) .command(generateProviders()) .command(generateProject()) .command(infoProject()) + .command(helpCommand()) .example("$0 new expressots-demo", "Create interactively") .example("$0 new expressots-demo -d ./", "Create interactively with path") .example("$0 new expressots-demo -p yarn -t opinionated", "Create silently") diff --git a/src/generate/cli.ts b/src/generate/cli.ts index bb533d2..4bfc81c 100644 --- a/src/generate/cli.ts +++ b/src/generate/cli.ts @@ -20,7 +20,7 @@ const coerceSchematicAliases = (arg: string) => { return "entity"; case "mo": return "module"; - case "m": + case "mi": return "middleware"; default: return arg; @@ -29,7 +29,7 @@ const coerceSchematicAliases = (arg: string) => { const generateProject = (): CommandModule => { return { - command: "generate [schematic] [path]", + command: "generate [schematic] [path] [method]", describe: "Scaffold a new resource", aliases: ["g"], builder: (yargs: Argv): Argv => { diff --git a/src/generate/form.ts b/src/generate/form.ts index 3ede542..8691332 100644 --- a/src/generate/form.ts +++ b/src/generate/form.ts @@ -1,476 +1,52 @@ -import * as nodePath from "path"; -import { mkdirSync, readFileSync } from "node:fs"; -import { render } from "mustache"; -import { writeFileSync, existsSync } from "fs"; -import chalk from "chalk"; -import { - anyCaseToCamelCase, - anyCaseToKebabCase, - anyCaseToPascalCase, - anyCaseToLowerCase, -} from "@expressots/boost-ts"; import Compiler from "../utils/compiler"; -import { Pattern } from "../types"; -import { addControllerToModule } from "../utils/add-controller-to-module"; -import { verifyIfFileExists } from "../utils/verify-file-exists"; -import { addModuleToContainer } from "../utils/add-module-to-container"; -import { printError } from "../utils/cli-ui"; - -function getFileNameWithoutExtension(filePath: string) { - return filePath.split(".")[0]; -} - +import { checkPathStyle } from "./utils/command-utils"; +import { nonOpinionatedProcess } from "./utils/nonopininated-cmd"; +import { opinionatedProcess } from "./utils/opinionated-cmd"; + +/** + * Create a template props + * @param schematic + * @param path + * @param method + */ type CreateTemplateProps = { schematic: string; path: string; method: string; }; +/** + * Create a template based on the schematic + * @param schematic - the schematic to create + * @param path - the path to create the schematic + * @param method - the http method + * @returns the file created + */ export const createTemplate = async ({ schematic, path: target, method, }: CreateTemplateProps) => { - const { opinionated, sourceRoot } = await Compiler.loadConfig(); - - if (sourceRoot === "") { - printError( - "You must specify a source root in your expressots.config.ts", - "sourceRoot", - ); - process.exit(1); - } - - let folderMatch = ""; - - if (opinionated) { - folderMatch = schematicFolder(schematic); - } else { - folderMatch = ""; - } - - const { path, file, className, moduleName, modulePath } = await splitTarget( - { target, schematic }, - ); - - const usecaseDir = `${sourceRoot}/${folderMatch}`; - - await verifyIfFileExists(`${usecaseDir}/${path}/${file}`); - - mkdirSync(`${usecaseDir}/${path}`, { recursive: true }); - - if (schematic !== "service") { - // add to guarantee that the routing will always be the last part of the path - let routeSchema = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - routeSchema = path.split("/").pop(); - } else { - routeSchema = path.replace(/\/$/, ""); - } - - let templateBasedSchematic = schematic; - if (schematic === "module") { - templateBasedSchematic = "module-default"; - } - - writeTemplate({ - outputPath: `${usecaseDir}/${path}/${file}`, - template: { - path: `./templates/${templateBasedSchematic}.tpl`, - data: { - className, - moduleName: className, - route: routeSchema, - construct: anyCaseToKebabCase(className), - method: getHttpMethod(method), - }, - }, - }); - } else { - for await (const resource of ["controller-service", "usecase", "dto"]) { - const currentSchematic = resource.replace( - "controller-service", - "controller", - ); - - const schematicFile = file.replace( - `controller.ts`, - `${currentSchematic}.ts`, - ); - - console.log( - " ", - chalk.greenBright(`[${currentSchematic}]`.padEnd(14)), - chalk.bold.white(`${schematicFile} created! āœ”ļø`), - ); - - let templateBasedMethod = ""; - if (method) { - if ( - resource === "controller-service" || - resource === "controller" - ) { - if (method === "get") - templateBasedMethod = `./templates/${resource}.tpl`; - else - templateBasedMethod = `./templates/${resource}-${method}.tpl`; - } else { - templateBasedMethod = `./templates/${resource}.tpl`; - } - - if (resource === "usecase") { - templateBasedMethod = `./templates/${resource}-op.tpl`; - } - - if (resource === "usecase") { - if (method === "get") - templateBasedMethod = `./templates/${resource}.tpl`; - if (method === "post") - templateBasedMethod = `./templates/${resource}-${method}.tpl`; - } - } else { - templateBasedMethod = `./templates/${resource}.tpl`; - } - - // add to guarantee that the routing will always be the last part of the path - let routeSchema = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - routeSchema = path.split("/").pop(); - } else { - routeSchema = path.replace(/\/$/, ""); - } - - writeTemplate({ - outputPath: `${usecaseDir}/${path}/${schematicFile}`, - template: { - path: templateBasedMethod, - data: { - className, - fileName: getFileNameWithoutExtension(file), - useCase: anyCaseToCamelCase(className), - route: routeSchema, //path.replace(/\/$/, ''), - construct: anyCaseToKebabCase(className), - method: getHttpMethod(method), - }, - }, - }); - } - } - - // Module generation - if (["controller", "service"].includes(schematic)) { - let moduleExist = false; - let moduleOutPath = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - if (modulePath === "") { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}.module.ts`; - } else { - moduleExist = existsSync( - `${usecaseDir}/${modulePath}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${modulePath}/${moduleName}.module.ts`; - } - } else { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - ); - if (modulePath === "") { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}.module.ts`; - } else { - moduleExist = existsSync( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - ); - moduleOutPath = `${usecaseDir}/${moduleName}/${moduleName}.module.ts`; - } - } - - let controllerPath = "./"; - const pathCount = path.split("/").length; - - if (path === "") { - controllerPath += `${file.slice(0, file.lastIndexOf("."))}`; - } else if (pathCount === 1) { - controllerPath += `${path}/${file.slice(0, file.lastIndexOf("."))}`; - } else if (pathCount === 2) { - controllerPath += `${path.split("/")[1]}/${file.slice( - 0, - file.lastIndexOf("."), - )}`; - } else { - const segments: string[] = path - .split("/") - .filter((segment) => segment !== ""); - controllerPath += `${segments[segments.length - 1]}/${file.slice( - 0, - file.lastIndexOf("."), - )}`; - } - - if (moduleExist) { - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - await addControllerToModule( - `${usecaseDir}/${modulePath}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } else { - if (modulePath === "") { - await addControllerToModule( - `${usecaseDir}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } else { - await addControllerToModule( - `${usecaseDir}/${moduleName}/${moduleName}.module.ts`, - `${className}Controller`, - controllerPath, - ); - } - } - } else { - writeTemplate({ - outputPath: moduleOutPath, - template: { - path: `./templates/module.tpl`, - data: { - moduleName: - moduleName[0].toUpperCase() + moduleName.slice(1), - className, - path: controllerPath, - }, - }, - }); - - console.log( - " ", - chalk.greenBright(`[module]`.padEnd(14)), - chalk.bold.white(`${moduleName}.module created! āœ”ļø`), - ); - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - await addModuleToContainer(moduleName, modulePath, path); - } else { - await addModuleToContainer(moduleName, moduleName, path); - } - } - } - - if (schematic === "service") { - console.log( - " ", - chalk.greenBright(`[${schematic}]`.padEnd(14)), - chalk.bold.yellow(`${file.split(".")[0]} created! āœ”ļø`), + const config = await Compiler.loadConfig(); + const pathStyle = checkPathStyle(target); + + let returnFile = ""; + if (config.opinionated) { + returnFile = await opinionatedProcess( + schematic, + target, + method, + config, + pathStyle, ); } else { - console.log( - " ", - chalk.greenBright(`[${schematic}]`.padEnd(14)), - chalk.bold.white(`${file.split(".")[0]} ${schematic} created! āœ”ļø`), + returnFile = await nonOpinionatedProcess( + schematic, + target, + method, + config, ); } - return file; -}; - -const splitTarget = async ({ - target, - schematic, -}: { - target: string; - schematic: string; -}): Promise<{ - path: string; - file: string; - className: string; - moduleName: string; - modulePath: string; -}> => { - const pathContent: string[] = target - .split("/") - .filter((item) => item !== ""); - const endsWithSlash: boolean = target.endsWith("/"); - let path = ""; - let fileName = ""; - let module = ""; - let modulePath = ""; - - if ( - target.includes("/") || - target.includes("\\") || - target.includes("//") - ) { - if (schematic === "service") schematic = "controller"; - if ( - schematic === "service" || - (schematic === "controller" && pathContent.length > 4) - ) { - printError("Max path depth is 4.", pathContent.join("/")); - process.exit(1); - } - - if (endsWithSlash) { - fileName = pathContent[pathContent.length - 1]; - path = pathContent.join("/"); - module = - pathContent.length == 1 - ? pathContent[pathContent.length - 1] - : pathContent[pathContent.length - 2]; - modulePath = pathContent.slice(0, -1).join("/"); - } else { - fileName = pathContent[pathContent.length - 1]; - path = pathContent.slice(0, -1).join("/"); - module = - pathContent.length == 2 - ? pathContent[pathContent.length - 2] - : pathContent[pathContent.length - 3]; - modulePath = pathContent.slice(0, -2).join("/"); - } - - return { - path, - file: `${await getNameWithScaffoldPattern( - fileName, - )}.${schematic}.ts`, - className: anyCaseToPascalCase(fileName), - moduleName: module, - modulePath, - }; - } else { - if (schematic === "service") schematic = "controller"; - // 1. Extract the name (first part of the target) - const [name, ...remainingPath] = target.split("/"); - // 2. Check if the name is camelCase or kebab-case - const camelCaseRegex = /[A-Z]/; - const kebabCaseRegex = /[_\-\s]+/; - const isCamelCase = camelCaseRegex.test(name); - const isKebabCase = kebabCaseRegex.test(name); - if (isCamelCase || isKebabCase) { - const [wordName, ...path] = name - ?.split(isCamelCase ? /(?=[A-Z])/ : kebabCaseRegex) - .map((word) => word.toLowerCase()); - - return { - path: `${wordName}/${pathEdgeCase(path)}${pathEdgeCase( - remainingPath, - )}`, - file: `${await getNameWithScaffoldPattern( - name, - )}.${schematic}.ts`, - className: anyCaseToPascalCase(name), - moduleName: wordName, - modulePath: pathContent[0].split("-")[1], - }; - } - - // 3. Return the base case - return { - path: "", - file: `${await getNameWithScaffoldPattern(name)}.${schematic}.ts`, - className: anyCaseToPascalCase(name), - moduleName: name, - modulePath: "", - }; - } -}; - -const getHttpMethod = (method: string): string => { - switch (method) { - case "put": - return "Put"; - case "post": - return "Post"; - case "patch": - return "Patch"; - case "delete": - return "Delete"; - default: - return "Get"; - } -}; - -const writeTemplate = ({ - outputPath, - template: { path, data }, -}: { - outputPath: string; - template: { - path: string; - data: Record; - }; -}) => { - writeFileSync( - outputPath, - render(readFileSync(nodePath.join(__dirname, path), "utf8"), data), - ); -}; - -const schematicFolder = (schematic: string): string | undefined => { - switch (schematic) { - case "usecase": - return "useCases"; - case "controller": - return "useCases"; - case "dto": - return "useCases"; - case "service": - return "useCases"; - case "provider": - return "providers"; - case "entity": - return "entities"; - case "middleware": - return "providers/middlewares"; - case "module": - return "useCases"; - } - - return undefined; -}; - -const getNameWithScaffoldPattern = async (name: string) => { - const configObject = await Compiler.loadConfig(); - - switch (configObject.scaffoldPattern) { - case Pattern.LOWER_CASE: - return anyCaseToLowerCase(name); - case Pattern.KEBAB_CASE: - return anyCaseToKebabCase(name); - case Pattern.PASCAL_CASE: - return anyCaseToPascalCase(name); - case Pattern.CAMEL_CASE: - return anyCaseToCamelCase(name); - } -}; -const pathEdgeCase = (path: string[]): string => { - return `${path.join("/")}${path.length > 0 ? "/" : ""}`; + return returnFile; }; diff --git a/src/generate/templates/dto-op.tpl b/src/generate/templates/dto-op.tpl deleted file mode 100644 index 767e15d..0000000 --- a/src/generate/templates/dto-op.tpl +++ /dev/null @@ -1,7 +0,0 @@ -export interface I{{className}}RequestDTO { - id: string; -} - -export interface I{{className}}ResponseDTO { } - - diff --git a/src/generate/templates/nonopinionated/controller.tpl b/src/generate/templates/nonopinionated/controller.tpl new file mode 100644 index 0000000..6aad6e4 --- /dev/null +++ b/src/generate/templates/nonopinionated/controller.tpl @@ -0,0 +1,10 @@ +import { BaseController } from "@expressots/core"; +import { controller, {{method}} } from "@expressots/adapter-express"; + +@controller("/{{{route}}}") +export class {{className}}{{schematic}} { + @{{method}}("/") + execute() { + return "{{schematic}}"; + } +} diff --git a/src/generate/templates/nonopinionated/dto.tpl b/src/generate/templates/nonopinionated/dto.tpl new file mode 100644 index 0000000..5fe9346 --- /dev/null +++ b/src/generate/templates/nonopinionated/dto.tpl @@ -0,0 +1,3 @@ +export interface I{{className}}Request{{schematic}} {} + +export interface I{{className}}Response{{schematic}} {} diff --git a/src/generate/templates/nonopinionated/entity.tpl b/src/generate/templates/nonopinionated/entity.tpl new file mode 100644 index 0000000..514cb5f --- /dev/null +++ b/src/generate/templates/nonopinionated/entity.tpl @@ -0,0 +1,4 @@ +import { provide } from "inversify-binding-decorators"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} {} diff --git a/src/generate/templates/nonopinionated/middleware.tpl b/src/generate/templates/nonopinionated/middleware.tpl new file mode 100644 index 0000000..a96c4b7 --- /dev/null +++ b/src/generate/templates/nonopinionated/middleware.tpl @@ -0,0 +1,10 @@ +import { ExpressoMiddleware } from "@expressots/core"; +import { NextFunction, Request, Response } from "express"; +import { provide } from "inversify-binding-decorators"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} extends ExpressoMiddleware { + use(req: Request, res: Response, next: NextFunction): void | Promise { + throw new Error("Method not implemented."); + } +} \ No newline at end of file diff --git a/src/generate/templates/nonopinionated/module.tpl b/src/generate/templates/nonopinionated/module.tpl new file mode 100644 index 0000000..beef305 --- /dev/null +++ b/src/generate/templates/nonopinionated/module.tpl @@ -0,0 +1,4 @@ +import { ContainerModule } from "inversify"; +import { CreateModule } from "@expressots/core"; + +export const {{moduleName}}{{schematic}}: ContainerModule = CreateModule([]); diff --git a/src/generate/templates/nonopinionated/provider.tpl b/src/generate/templates/nonopinionated/provider.tpl new file mode 100644 index 0000000..808f1d1 --- /dev/null +++ b/src/generate/templates/nonopinionated/provider.tpl @@ -0,0 +1,4 @@ +import { provide } from "inversify-binding-decorators"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} {} \ No newline at end of file diff --git a/src/generate/templates/nonopinionated/usecase.tpl b/src/generate/templates/nonopinionated/usecase.tpl new file mode 100644 index 0000000..12fa4e7 --- /dev/null +++ b/src/generate/templates/nonopinionated/usecase.tpl @@ -0,0 +1,8 @@ +import { provide } from "inversify-binding-decorators"; + +@provide({{className}}{{schematic}}) +export class {{className}}{{schematic}} { + execute() { + return "{{schematic}}"; + } +} diff --git a/src/generate/templates/controller-service-delete.tpl b/src/generate/templates/opinionated/controller-service-delete.tpl similarity index 85% rename from src/generate/templates/controller-service-delete.tpl rename to src/generate/templates/opinionated/controller-service-delete.tpl index e4c9206..7d6c0e2 100644 --- a/src/generate/templates/controller-service-delete.tpl +++ b/src/generate/templates/opinionated/controller-service-delete.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, param, response } from "@expressots/adapter-express"; +import { controller, Delete, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/:id") + @Delete("/:id") execute(@param("id") id: string, @response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(id), diff --git a/src/generate/templates/controller-service.tpl b/src/generate/templates/opinionated/controller-service-get.tpl similarity index 85% rename from src/generate/templates/controller-service.tpl rename to src/generate/templates/opinionated/controller-service-get.tpl index 13790b2..ed8ad1f 100644 --- a/src/generate/templates/controller-service.tpl +++ b/src/generate/templates/opinionated/controller-service-get.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, response } from "@expressots/adapter-express"; +import { controller, Get, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Get("/") execute(@response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(), diff --git a/src/generate/templates/controller-service-patch.tpl b/src/generate/templates/opinionated/controller-service-patch.tpl similarity index 86% rename from src/generate/templates/controller-service-patch.tpl rename to src/generate/templates/opinionated/controller-service-patch.tpl index d689f08..c7dd160 100644 --- a/src/generate/templates/controller-service-patch.tpl +++ b/src/generate/templates/opinionated/controller-service-patch.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, param, response } from "@expressots/adapter-express"; +import { controller, Patch, body, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Patch("/") execute( @body() payload: I{{className}}RequestDTO, @response() res: Response, diff --git a/src/generate/templates/controller-service-post.tpl b/src/generate/templates/opinionated/controller-service-post.tpl similarity index 86% rename from src/generate/templates/controller-service-post.tpl rename to src/generate/templates/opinionated/controller-service-post.tpl index 39d54e1..ac8481d 100644 --- a/src/generate/templates/controller-service-post.tpl +++ b/src/generate/templates/opinionated/controller-service-post.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, response } from "@expressots/adapter-express"; +import { controller, Post, body, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Post("/") execute(@body() payload: I{{className}}RequestDTO, @response() res: Response): I{{className}}ResponseDTO { return this.callUseCase( this.{{useCase}}UseCase.execute(payload), diff --git a/src/generate/templates/controller-service-put.tpl b/src/generate/templates/opinionated/controller-service-put.tpl similarity index 86% rename from src/generate/templates/controller-service-put.tpl rename to src/generate/templates/opinionated/controller-service-put.tpl index d689f08..806c782 100644 --- a/src/generate/templates/controller-service-put.tpl +++ b/src/generate/templates/opinionated/controller-service-put.tpl @@ -1,5 +1,5 @@ import { BaseController, StatusCode } from "@expressots/core"; -import { controller, {{method}}, body, param, response } from "@expressots/adapter-express"; +import { controller, Put, body, param, response } from "@expressots/adapter-express"; import { Response } from "express"; import { {{className}}UseCase } from "./{{fileName}}.usecase"; import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; @@ -10,7 +10,7 @@ export class {{className}}Controller extends BaseController { super(); } - @{{method}}("/") + @Put("/") execute( @body() payload: I{{className}}RequestDTO, @response() res: Response, diff --git a/src/generate/templates/controller.tpl b/src/generate/templates/opinionated/controller-service.tpl similarity index 100% rename from src/generate/templates/controller.tpl rename to src/generate/templates/opinionated/controller-service.tpl diff --git a/src/generate/templates/dto.tpl b/src/generate/templates/opinionated/dto.tpl similarity index 100% rename from src/generate/templates/dto.tpl rename to src/generate/templates/opinionated/dto.tpl diff --git a/src/generate/templates/entity.tpl b/src/generate/templates/opinionated/entity.tpl similarity index 73% rename from src/generate/templates/entity.tpl rename to src/generate/templates/opinionated/entity.tpl index 2c6121e..28c4d3b 100644 --- a/src/generate/templates/entity.tpl +++ b/src/generate/templates/opinionated/entity.tpl @@ -1,8 +1,8 @@ import { provide } from "inversify-binding-decorators"; import { randomUUID } from "node:crypto"; -@provide({{className}}) -export class {{className}} { +@provide({{className}}Entity) +export class {{className}}Entity { id: string; constructor() { diff --git a/src/generate/templates/middleware.tpl b/src/generate/templates/opinionated/middleware.tpl similarity index 100% rename from src/generate/templates/middleware.tpl rename to src/generate/templates/opinionated/middleware.tpl diff --git a/src/generate/templates/module.tpl b/src/generate/templates/opinionated/module-service.tpl similarity index 100% rename from src/generate/templates/module.tpl rename to src/generate/templates/opinionated/module-service.tpl diff --git a/src/generate/templates/module-default.tpl b/src/generate/templates/opinionated/module.tpl similarity index 100% rename from src/generate/templates/module-default.tpl rename to src/generate/templates/opinionated/module.tpl diff --git a/src/generate/templates/provider.tpl b/src/generate/templates/opinionated/provider.tpl similarity index 100% rename from src/generate/templates/provider.tpl rename to src/generate/templates/opinionated/provider.tpl diff --git a/src/generate/templates/opinionated/usecase-service-delete.tpl b/src/generate/templates/opinionated/usecase-service-delete.tpl new file mode 100644 index 0000000..6c8d1cc --- /dev/null +++ b/src/generate/templates/opinionated/usecase-service-delete.tpl @@ -0,0 +1,8 @@ +import { provide } from "inversify-binding-decorators"; + +@provide({{className}}UseCase) +export class {{className}}UseCase { + execute(id: string) { + return "Use Case"; + } +} diff --git a/src/generate/templates/usecase-op.tpl b/src/generate/templates/opinionated/usecase-service.tpl similarity index 100% rename from src/generate/templates/usecase-op.tpl rename to src/generate/templates/opinionated/usecase-service.tpl diff --git a/src/generate/templates/usecase.tpl b/src/generate/templates/opinionated/usecase.tpl similarity index 100% rename from src/generate/templates/usecase.tpl rename to src/generate/templates/opinionated/usecase.tpl diff --git a/src/generate/templates/usecase-post.tpl b/src/generate/templates/usecase-post.tpl deleted file mode 100644 index 5c391e8..0000000 --- a/src/generate/templates/usecase-post.tpl +++ /dev/null @@ -1,9 +0,0 @@ -import { provide } from "inversify-binding-decorators"; -import { I{{className}}RequestDTO, I{{className}}ResponseDTO } from "./{{fileName}}.dto"; - -@provide({{className}}UseCase) -export class {{className}}UseCase { - execute(payload: I{{className}}RequestDTO): I{{className}}ResponseDTO { - return "Use Case"; - } -} \ No newline at end of file diff --git a/src/generate/utils/command-utils.ts b/src/generate/utils/command-utils.ts new file mode 100644 index 0000000..3fd1633 --- /dev/null +++ b/src/generate/utils/command-utils.ts @@ -0,0 +1,391 @@ +import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs"; +import * as nodePath from "node:path"; +import { render } from "mustache"; +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, + anyCaseToLowerCase, +} from "@expressots/boost-ts"; + +import { printError } from "../../utils/cli-ui"; +import { verifyIfFileExists } from "../../utils/verify-file-exists"; +import Compiler from "../../utils/compiler"; +import { ExpressoConfig, Pattern } from "../../types"; + +export const enum PathStyle { + None = "none", + Single = "single", + Nested = "nested", + Sugar = "sugar", +} + +/** + * File preparation + * @param schematic + * @param target + * @param method + * @param opinionated + * @param sourceRoot + * @returns the file output + */ +export type FilePreparation = { + schematic: string; + target: string; + method: string; + expressoConfig: ExpressoConfig; +}; + +/** + * File output + * @param path + * @param file + * @param className + * @param moduleName + * @param modulePath + * @param outputPath + * @param folderToScaffold + */ +export type FileOutput = { + path: string; + file: string; + className: string; + moduleName: string; + modulePath: string; + outputPath: string; + folderToScaffold: string; + fileName: string; + schematic: string; +}; + +/** + * Create a template based on the schematic + * @param fp + * @returns the file created + */ +export async function validateAndPrepareFile(fp: FilePreparation) { + const { sourceRoot, scaffoldSchematics, opinionated } = fp.expressoConfig; + if (sourceRoot === "") { + printError( + "You must specify a source root in your expressots.config.ts", + "sourceRoot", + ); + process.exit(1); + } + + if (opinionated) { + const folderSchematic = schematicFolder(fp.schematic); + + const folderToScaffold = `${sourceRoot}/${folderSchematic}`; + const { path, file, className, moduleName, modulePath } = + await splitTarget({ + target: fp.target, + schematic: fp.schematic, + }); + + const outputPath = `${folderToScaffold}/${path}/${file}`; + await verifyIfFileExists(outputPath, fp.schematic); + mkdirSync(`${folderToScaffold}/${path}`, { recursive: true }); + + return { + path, + file, + className, + moduleName, + modulePath, + outputPath, + folderToScaffold, + fileName: getFileNameWithoutExtension(file), + schematic: fp.schematic, + }; + } + + const folderSchematic = ""; + + const folderToScaffold = `${sourceRoot}/${folderSchematic}`; + const { path, file, className, moduleName, modulePath } = await splitTarget( + { + target: fp.target, + schematic: fp.schematic, + }, + ); + + const fileBaseSchema = + scaffoldSchematics?.[fp.schematic as keyof typeof scaffoldSchematics]; + const validateFileSchema = + fileBaseSchema !== undefined + ? file.replace(fp.schematic, fileBaseSchema) + : file; + + const outputPath = `${folderToScaffold}/${path}/${validateFileSchema}`; + await verifyIfFileExists(outputPath, fp.schematic); + mkdirSync(`${folderToScaffold}/${path}`, { recursive: true }); + + return { + path, + file, + className, + moduleName, + modulePath, + outputPath, + folderToScaffold, + fileName: getFileNameWithoutExtension(file), + schematic: fileBaseSchema !== undefined ? fileBaseSchema : fp.schematic, + }; +} + +/** + * Get the file name without the extension + * @param filePath + * @returns the file name + */ +export function getFileNameWithoutExtension(filePath: string) { + return filePath.split(".")[0]; +} + +/** + * Split the target into path, file, class name, module name and module path + * @param target + * @param schematic + * @returns the split target + */ +export const splitTarget = async ({ + target, + schematic, +}: { + target: string; + schematic: string; +}): Promise<{ + path: string; + file: string; + className: string; + moduleName: string; + modulePath: string; +}> => { + const pathContent: string[] = target + .split("/") + .filter((item) => item !== ""); + const endsWithSlash: boolean = target.endsWith("/"); + let path = ""; + let fileName = ""; + let module = ""; + let modulePath = ""; + + if ( + target.includes("/") || + target.includes("\\") || + target.includes("//") + ) { + if (schematic === "service") schematic = "controller"; + if ( + schematic === "service" || + (schematic === "controller" && pathContent.length > 4) + ) { + printError("Max path depth is 4.", pathContent.join("/")); + process.exit(1); + } + + if (endsWithSlash) { + fileName = pathContent[pathContent.length - 1]; + path = pathContent.join("/"); + module = + pathContent.length == 1 + ? pathContent[pathContent.length - 1] + : pathContent[pathContent.length - 2]; + modulePath = pathContent.slice(0, -1).join("/"); + } else { + fileName = pathContent[pathContent.length - 1]; + path = pathContent.slice(0, -1).join("/"); + module = + pathContent.length == 2 + ? pathContent[pathContent.length - 2] + : pathContent[pathContent.length - 3]; + modulePath = pathContent.slice(0, -2).join("/"); + } + + return { + path, + file: `${await getNameWithScaffoldPattern( + fileName, + )}.${schematic}.ts`, + className: anyCaseToPascalCase(fileName), + moduleName: module, + modulePath, + }; + } else { + if (schematic === "service") schematic = "controller"; + // 1. Extract the name (first part of the target) + const [name, ...remainingPath] = target.split("/"); + // 2. Check if the name is camelCase or kebab-case + const camelCaseRegex = /[A-Z]/; + const kebabCaseRegex = /[_\-\s]+/; + const isCamelCase = camelCaseRegex.test(name); + const isKebabCase = kebabCaseRegex.test(name); + if (isCamelCase || isKebabCase) { + const [wordName, ...path] = name + ?.split(isCamelCase ? /(?=[A-Z])/ : kebabCaseRegex) + .map((word) => word.toLowerCase()); + + return { + path: `${wordName}/${pathEdgeCase(path)}${pathEdgeCase( + remainingPath, + )}`, + file: `${await getNameWithScaffoldPattern( + name, + )}.${schematic}.ts`, + className: anyCaseToPascalCase(name), + moduleName: wordName, + modulePath: pathContent[0].split("-")[1], + }; + } + + // 3. Return the base case + return { + path: "", + file: `${await getNameWithScaffoldPattern(name)}.${schematic}.ts`, + className: anyCaseToPascalCase(name), + moduleName: name, + modulePath: "", + }; + } +}; + +/** + * Write the template based on the http method + * @param method - the http method + * @returns decorator - the decorator to be used + */ +export const getHttpMethod = (method: string): string => { + switch (method) { + case "put": + return "Put"; + case "post": + return "Post"; + case "patch": + return "Patch"; + case "delete": + return "Delete"; + default: + return "Get"; + } +}; + +/** + * Write the template based on the schematics + * @param outputPath - the output path + * @param template - the template to be used + * @returns void + */ +export const writeTemplate = ({ + outputPath, + template: { path, data }, +}: { + outputPath: string; + template: { + path: string; + data: Record; + }; +}) => { + writeFileSync( + outputPath, + render(readFileSync(nodePath.join(__dirname, path), "utf8"), data), + ); +}; + +/** + * Returns the folder where the schematic should be placed + * @param schematic + */ +export const schematicFolder = (schematic: string): string | undefined => { + switch (schematic) { + case "usecase": + return "useCases"; + case "controller": + return "useCases"; + case "dto": + return "useCases"; + case "service": + return "useCases"; + case "provider": + return "providers"; + case "entity": + return "entities"; + case "middleware": + return "providers/middlewares"; + case "module": + return "useCases"; + } + + return undefined; +}; + +/** + * Get the name with the scaffold pattern + * @param name + * @returns the name in the scaffold pattern + */ +export const getNameWithScaffoldPattern = async (name: string) => { + const configObject = await Compiler.loadConfig(); + + switch (configObject.scaffoldPattern) { + case Pattern.LOWER_CASE: + return anyCaseToLowerCase(name); + case Pattern.KEBAB_CASE: + return anyCaseToKebabCase(name); + case Pattern.PASCAL_CASE: + return anyCaseToPascalCase(name); + case Pattern.CAMEL_CASE: + return anyCaseToCamelCase(name); + } +}; + +/** + * Get the path edge case + * @param path + * @returns the path edge case from the last element of the path + */ +const pathEdgeCase = (path: string[]): string => { + return `${path.join("/")}${path.length > 0 ? "/" : ""}`; +}; + +/** + * Extract the first word from a file and convert it to the scaffold pattern + * @param file + * @returns the first word in the scaffold pattern + */ +export async function extractFirstWord(file: string) { + const f = file.split(".")[0]; + + const regex = /(?:-|(?<=[a-z])(?=[A-Z]))/; + const firstWord = f.split(regex)[0]; + + const config = await Compiler.loadConfig(); + switch (config.scaffoldPattern) { + case Pattern.LOWER_CASE: + return anyCaseToLowerCase(firstWord); + case Pattern.KEBAB_CASE: + return anyCaseToKebabCase(firstWord); + case Pattern.PASCAL_CASE: + return anyCaseToPascalCase(firstWord); + case Pattern.CAMEL_CASE: + return anyCaseToCamelCase(firstWord); + } +} + +/** + * Check if the path is a nested path, a single path or a sugar path + * @param path + * @returns the path style + */ +export const checkPathStyle = (path: string): PathStyle => { + const singleOrNestedPathRegex = /\/|\\/; + const sugarPathRegex = /^\w+-\w+$/; + + if (singleOrNestedPathRegex.test(path)) { + return PathStyle.Nested; + } else if (sugarPathRegex.test(path)) { + return PathStyle.Sugar; + } else { + return PathStyle.Single; + } +}; diff --git a/src/generate/utils/nonopininated-cmd.ts b/src/generate/utils/nonopininated-cmd.ts new file mode 100644 index 0000000..2a3f44b --- /dev/null +++ b/src/generate/utils/nonopininated-cmd.ts @@ -0,0 +1,389 @@ +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, +} from "@expressots/boost-ts"; +import { ExpressoConfig } from "../../@types"; + +import { printGenerateSuccess } from "../../utils/cli-ui"; +import { + FileOutput, + getFileNameWithoutExtension, + getHttpMethod, + validateAndPrepareFile, + writeTemplate, +} from "./command-utils"; + +/** + * Process the non-opinionated command + * @param schematic - The schematic + * @param target - The target + * @param method - The method + * @param expressoConfig - The expresso config + */ +export async function nonOpinionatedProcess( + schematic: string, + target: string, + method: string, + expressoConfig: ExpressoConfig, +): Promise { + let f: FileOutput = await validateAndPrepareFile({ + schematic, + target, + method, + expressoConfig, + }); + switch (schematic) { + case "service": + f = await validateAndPrepareFile({ + schematic: "controller", + target, + method, + expressoConfig, + }); + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "usecase", + target, + method, + expressoConfig, + }); + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + f.schematic, + "../templates/nonopinionated/usecase.tpl", + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "dto", + target, + method, + expressoConfig, + }); + await generateDTO( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + + f = await validateAndPrepareFile({ + schematic: "module", + target, + method, + expressoConfig, + }); + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "usecase": + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "controller": + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "dto": + await generateDTO( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "provider": + await generateProvider( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "entity": + await generateEntity( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "middleware": + await generateMiddleware( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + case "module": + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.schematic, + ); + await printGenerateSuccess(f.schematic, f.file); + break; + } + + return f.file; +} + +/* Generate Resource */ +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param template - The template + */ +async function generateUseCase( + outputPath: string, + className: string, + moduleName: string, + path: string, + fileName: string, + schematic: string, + template?: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: template + ? template + : "../templates/nonopinionated/usecase.tpl", + data: { + className, + moduleName, + path, + fileName, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a controller + * @param outputPath - The output path + * @param className - The class name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateController( + outputPath: string, + className: string, + path: string, + method: string, + file: string, + schematic: string, +): Promise { + const templateBasedMethod = "../templates/nonopinionated/controller.tpl"; + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: className + ? className.toLowerCase() + : path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a DTO + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateDTO( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/dto.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a provider + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateProvider( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/provider.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate an entity + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateEntity( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/entity.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a middleware + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateMiddleware( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/middleware.tpl", + data: { + className, + moduleName, + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} + +/** + * Generate a module + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModule( + outputPath: string, + className: string, + moduleName: string, + path: string, + schematic: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/nonopinionated/module.tpl", + data: { + className, + moduleName: className + ? anyCaseToPascalCase(className) + : anyCaseToPascalCase(moduleName), + path, + schematic: anyCaseToPascalCase(schematic), + }, + }, + }); +} diff --git a/src/generate/utils/opinionated-cmd.ts b/src/generate/utils/opinionated-cmd.ts new file mode 100644 index 0000000..2688eb1 --- /dev/null +++ b/src/generate/utils/opinionated-cmd.ts @@ -0,0 +1,676 @@ +import { + anyCaseToCamelCase, + anyCaseToKebabCase, + anyCaseToPascalCase, +} from "@expressots/boost-ts"; +import * as nodePath from "node:path"; +import fs from "fs"; +import { printGenerateSuccess } from "../../utils/cli-ui"; +import { + extractFirstWord, + FileOutput, + getFileNameWithoutExtension, + getHttpMethod, + PathStyle, + validateAndPrepareFile, + writeTemplate, +} from "./command-utils"; +import { addControllerToModule } from "../../utils/add-controller-to-module"; +import { + addModuleToContainer, + addModuleToContainerNestedPath, +} from "../../utils/add-module-to-container"; +import { ExpressoConfig } from "../../@types"; + +/** + * Process commands for opinionated service scaffolding + * @param schematic - Resource to scaffold + * @param target - Target path + * @param method - HTTP method + * @param expressoConfig - Expresso configuration [expressots.config.ts] + * @param pathStyle - Path command style [sugar, nested, single] + * @returns + */ +export async function opinionatedProcess( + schematic: string, + target: string, + method: string, + expressoConfig: ExpressoConfig, + pathStyle: string, +): Promise { + const f: FileOutput = await validateAndPrepareFile({ + schematic, + target, + method, + expressoConfig, + }); + switch (schematic) { + case "service": + await generateControllerService( + f.outputPath, + f.className, + f.path, + method, + f.file, + ); + + const u = await validateAndPrepareFile({ + schematic: "usecase", + target, + method, + expressoConfig, + }); + await generateUseCaseService( + u.outputPath, + u.className, + method, + u.moduleName, + u.path, + u.fileName, + ); + + const d = await validateAndPrepareFile({ + schematic: "dto", + target, + method, + expressoConfig, + }); + await generateDTO(d.outputPath, d.className, d.moduleName, d.path); + + const m = await validateAndPrepareFile({ + schematic: "module", + target, + method, + expressoConfig, + }); + + if (pathStyle === PathStyle.Sugar) { + await generateModuleServiceSugarPath( + f.outputPath, + m.className, + m.moduleName, + m.path, + m.file, + m.folderToScaffold, + ); + } else if (pathStyle === PathStyle.Nested) { + await generateModuleServiceNestedPath( + f.outputPath, + m.className, + m.path, + m.folderToScaffold, + ); + } else if (pathStyle === PathStyle.Single) { + await generateModuleServiceSinglePath( + f.outputPath, + m.className, + m.moduleName, + m.path, + m.file, + m.folderToScaffold, + ); + } + + await printGenerateSuccess("controller", f.file); + await printGenerateSuccess("usecase", f.file); + await printGenerateSuccess("dto", f.file); + await printGenerateSuccess("module", f.file); + break; + case "usecase": + await generateUseCase( + f.outputPath, + f.className, + f.moduleName, + f.path, + f.fileName, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "controller": + await generateController( + f.outputPath, + f.className, + f.path, + method, + f.file, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "dto": + await generateDTO(f.outputPath, f.className, f.moduleName, f.path); + await printGenerateSuccess(schematic, f.file); + break; + case "provider": + await generateProvider( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "entity": + await generateEntity( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "middleware": + await generateMiddleware( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + case "module": + await generateModule( + f.outputPath, + f.className, + f.moduleName, + f.path, + ); + await printGenerateSuccess(schematic, f.file); + break; + } + + return f.file; +} + +/* Generate Resource */ + +/** + * Generate a controller service + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateControllerService( + outputPath: string, + className: string, + path: string, + method: string, + file: string, +): Promise { + let templateBasedMethod = ""; + + switch (method) { + case "put": + templateBasedMethod = + "../templates/opinionated/controller-service-put.tpl"; + break; + case "patch": + templateBasedMethod = + "../templates/opinionated/controller-service-patch.tpl"; + break; + case "post": + templateBasedMethod = + "../templates/opinionated/controller-service-post.tpl"; + break; + case "delete": + templateBasedMethod = + "../templates/opinionated/controller-service-delete.tpl"; + break; + default: + templateBasedMethod = + "../templates/opinionated/controller-service-get.tpl"; + break; + } + + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + }, + }, + }); +} + +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param template - The template + */ +async function generateUseCaseService( + outputPath: string, + className: string, + method: string, + moduleName: string, + path: string, + fileName: string, +): Promise { + let templateBasedMethod = ""; + + switch (method) { + case "put": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "patch": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "post": + templateBasedMethod = + "../templates/opinionated/usecase-service.tpl"; + break; + case "delete": + templateBasedMethod = + "../templates/opinionated/usecase-service-delete.tpl"; + break; + default: + templateBasedMethod = "../templates/opinionated/usecase.tpl"; + break; + } + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + moduleName, + path, + fileName, + }, + }, + }); +} + +/** + * Generate a use case + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + * @param fileName - The file name + */ +async function generateUseCase( + outputPath: string, + className: string, + moduleName: string, + path: string, + fileName: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/usecase.tpl", + data: { + className, + moduleName, + path, + fileName, + }, + }, + }); +} + +/** + * Generate a controller + * @param outputPath - The output path + * @param className - The class name + * @param path - The path + * @param method - The method + * @param file - The file + */ +async function generateController( + outputPath: string, + className: string, + path: string, + method: string, + file: string, +): Promise { + const templateBasedMethod = + "../templates/opinionated/controller-service.tpl"; + + writeTemplate({ + outputPath, + template: { + path: templateBasedMethod, + data: { + className, + fileName: getFileNameWithoutExtension(file), + useCase: anyCaseToCamelCase(className), + route: path.replace(/\/$/, ""), + construct: anyCaseToKebabCase(className), + method: getHttpMethod(method), + }, + }, + }); +} + +/** + * Generate a DTO + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateDTO( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/dto.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a provider + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateProvider( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/provider.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate an entity + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateEntity( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/entity.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a middleware + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateMiddleware( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/middleware.tpl", + data: { + className, + moduleName, + path, + }, + }, + }); +} + +/** + * Generate a module for service scaffolding with sugar path + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModuleServiceSugarPath( + outputPathController: string, + className: string, + moduleName: string, + path: string, + file: string, + folderToScaffold: string, +): Promise { + const newModuleFile = await extractFirstWord(file); + const newModulePath = nodePath + .join(folderToScaffold, path, "..") + .normalize(); + const newModuleName = `${newModuleFile}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainer( + anyCaseToPascalCase(moduleName), + `${moduleName}/${file.replace(".ts", "")}`, + path, + ); +} + +/** + * Generate a module for service scaffolding with single path + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModuleServiceSinglePath( + outputPathController: string, + className: string, + moduleName: string, + path: string, + file: string, + folderToScaffold: string, +): Promise { + const newModuleFile = await extractFirstWord(file); + const newModulePath = nodePath.join(folderToScaffold, path).normalize(); + const newModuleName = `${newModuleFile}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainer( + anyCaseToPascalCase(moduleName), + `${moduleName}/${file.replace(".ts", "")}`, + path, + ); +} + +/** + * Generate a module for service scaffolding with nested path + * @param outputPathController + * @param className + * @param path + * @param folderToScaffold + * @returns + */ +async function generateModuleServiceNestedPath( + outputPathController: string, + className: string, + path: string, + folderToScaffold: string, +): Promise { + const moduleFileName = nodePath.basename(path, "/"); + const newModulePath = nodePath + .join(folderToScaffold, path, "..") + .normalize(); + + const newModuleName = `${moduleFileName}.module.ts`; + const newModuleOutputPath = `${newModulePath}/${newModuleName}`.replace( + "\\", + "/", + ); + + const controllerToModule = nodePath + .relative(newModuleOutputPath, outputPathController) + .normalize() + .replace(/\.ts$/, "") + .replace(/\\/g, "/") + .replace(/\.\./g, "."); + + const controllerFullPath = nodePath + .join(folderToScaffold, path, "..", newModuleName) + .normalize(); + + if (fs.existsSync(newModuleOutputPath)) { + await addControllerToModule( + controllerFullPath, + `${className}Controller`, + controllerToModule, + ); + return; + } + + writeTemplate({ + outputPath: newModuleOutputPath, + template: { + path: "../templates/opinionated/module-service.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleFileName), + path: controllerToModule, + }, + }, + }); + + await addModuleToContainerNestedPath( + anyCaseToPascalCase(moduleFileName), + path, + ); +} + +/** + * Generate a module + * @param outputPath - The output path + * @param className - The class name + * @param moduleName - The module name + * @param path - The path + */ +async function generateModule( + outputPath: string, + className: string, + moduleName: string, + path: string, +): Promise { + writeTemplate({ + outputPath, + template: { + path: "../templates/opinionated/module.tpl", + data: { + className, + moduleName: anyCaseToPascalCase(moduleName), + path, + }, + }, + }); +} diff --git a/src/help/cli.ts b/src/help/cli.ts new file mode 100644 index 0000000..489ce58 --- /dev/null +++ b/src/help/cli.ts @@ -0,0 +1,18 @@ +import { CommandModule } from "yargs"; +import { helpForm } from "./form"; + +// eslint-disable-next-line @typescript-eslint/ban-types +type CommandModuleArgs = {}; + +const helpCommand = (): CommandModule => { + return { + command: "resources", + describe: "Resource list", + aliases: ["r"], + handler: async () => { + await helpForm(); + }, + }; +}; + +export { helpCommand }; diff --git a/src/help/form.ts b/src/help/form.ts new file mode 100644 index 0000000..3e689e3 --- /dev/null +++ b/src/help/form.ts @@ -0,0 +1,46 @@ +import chalk from "chalk"; +import CliTable3 from "cli-table3"; + +const helpForm = async (): Promise => { + const table = new CliTable3({ + head: [ + chalk.green("Name"), + chalk.green("Alias"), + chalk.green("Description"), + ], + colWidths: [15, 10, 60], + }); + + table.push( + ["new project", "new", "Generate a new project"], + ["info", "i", "Provides project information"], + ["resources", "r", "Displays cli commands and resources"], + ["help", "h", "Show command help"], + [ + "service", + "g s", + "Generate a service [controller, usecase, dto, module]", + ], + ["controller", "g c", "Generate a controller"], + ["usecase", "g u", "Generate a usecase"], + ["dto", "g d", "Generate a dto"], + ["entity", "g e", "Generate an entity"], + ["provider", "g p", "Generate a provider"], + ["module", "g mo", "Generate a module"], + ["middleware", "g mi", "Generate a middleware"], + ); + console.log( + chalk.bold.white("ExpressoTS:", `${chalk.green("Resources List")}`), + ); + console.log(chalk.whiteBright(table.toString())); + console.log( + chalk.bold.white( + `šŸ“ More info: ${chalk.green( + "https://doc.expresso-ts.com/docs/category/cli", + )}`, + ), + ); + console.log("\n"); +}; + +export { helpForm }; diff --git a/src/help/index.ts b/src/help/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/info/form.ts b/src/info/form.ts index 321417d..43016bc 100644 --- a/src/info/form.ts +++ b/src/info/form.ts @@ -2,7 +2,6 @@ import chalk from "chalk"; import path from "path"; import fs from "fs"; import os from "os"; -import { CLI_VERSION } from "../cli"; import { printError } from "../utils/cli-ui"; function getInfosFromPackage() { @@ -16,10 +15,10 @@ function getInfosFromPackage() { const packageJson = JSON.parse(fileContents); console.log(chalk.green("ExpressoTS Project:")); - console.log(chalk.bold(`\tName: ${packageJson.name}`)); - console.log(chalk.bold(`\tDescription: ${packageJson.description}`)); - console.log(chalk.bold(`\tVersion: ${packageJson.version}`)); - console.log(chalk.bold(`\tAuthor: ${packageJson.author}`)); + console.log(chalk.white(`\tName: ${packageJson.name}`)); + console.log(chalk.white(`\tDescription: ${packageJson.description}`)); + console.log(chalk.white(`\tVersion: ${packageJson.version}`)); + console.log(chalk.white(`\tAuthor: ${packageJson.author}`)); } catch (error) { printError( "No project information available.", @@ -28,15 +27,12 @@ function getInfosFromPackage() { } } -const infoForm = async (): Promise => { - console.log(chalk.green("System informations:")); - console.log(chalk.bold(`\tOS Version: ${os.version()}`)); - console.log(chalk.bold(`\tNodeJS version: ${process.version}`)); - - console.log(chalk.green("CLI Version:")); - console.log(chalk.bold(`\tCurrent version: v${CLI_VERSION}`)); - +const infoForm = (): void => { getInfosFromPackage(); + + console.log(chalk.green("System information:")); + console.log(chalk.white(`\tOS Version: ${os.version()}`)); + console.log(chalk.white(`\tNodeJS version: ${process.version}`)); }; export { infoForm }; diff --git a/src/new/form.ts b/src/new/form.ts index 00d00d6..f2430f8 100644 --- a/src/new/form.ts +++ b/src/new/form.ts @@ -211,6 +211,7 @@ const projectForm = async (projectName: string, args: any[]): Promise => { await emitter.clone(answer.name); } catch (err: any) { + console.log("\n"); printError( "Project already exists or Folder is not empty", answer.name, diff --git a/src/utils/add-controller-to-module.ts b/src/utils/add-controller-to-module.ts index dce6560..362078b 100644 --- a/src/utils/add-controller-to-module.ts +++ b/src/utils/add-controller-to-module.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; -async function addControllerToModule( +export async function addControllerToModule( filePath: string, controllerName: string, controllerPath: string, @@ -54,5 +54,3 @@ async function addControllerToModule( await fs.promises.writeFile(filePath, newFileContent, "utf8"); } - -export { addControllerToModule }; diff --git a/src/utils/add-module-to-container.ts b/src/utils/add-module-to-container.ts index f913eea..0700757 100644 --- a/src/utils/add-module-to-container.ts +++ b/src/utils/add-module-to-container.ts @@ -90,12 +90,12 @@ async function addModuleToContainer( if (!modulePathRegex.test(modulePath)) { if (path.split("/").length > 1) { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${modulePath}/${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name.toLowerCase()}/${name.toLowerCase()}.module";`; } else { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name.toLowerCase()}.module";`; } } else { - newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}/${name}.module";`; + newImport = `import { ${moduleName}Module } from "${usecaseDir}${name}/${name.toLowerCase()}.module";`; } if ( @@ -127,4 +127,52 @@ async function addModuleToContainer( await fs.promises.writeFile(containerData.path, newFileContent, "utf8"); } -export { addModuleToContainer }; +async function addModuleToContainerNestedPath(name: string, path?: string) { + const containerData: AppContainerType = await validateAppContainer(); + + const moduleName = (name[0].toUpperCase() + name.slice(1)).trimStart(); + const { opinionated } = await Compiler.loadConfig(); + + let usecaseDir: string; + if (opinionated) { + usecaseDir = `@useCases/`; + } else { + usecaseDir = `./`; + } + + if (path.endsWith("/")) { + path = path.slice(0, -1); + } + + const newImport = `import { ${moduleName}Module } from "${usecaseDir}${path}.module";`; + + if ( + containerData.imports.includes(newImport) && + containerData.modules.includes(`${moduleName}Module`) + ) { + return; + } + + containerData.imports.push(newImport); + containerData.modules.push(`${moduleName}Module`); + + const newModule = containerData.modules.join(", "); + const newModuleDeclaration = `.create([${newModule}]`; + + const newFileContent = [ + ...containerData.imports, + ...containerData.notImports, + ] + .join("\n") + .replace(containerData.regex, newModuleDeclaration); + + console.log( + " ", + chalk.greenBright(`[container]`.padEnd(14)), + chalk.bold.white(`${moduleName}Module added to ${APP_CONTAINER}! āœ”ļø`), + ); + + await fs.promises.writeFile(containerData.path, newFileContent, "utf8"); +} + +export { addModuleToContainer, addModuleToContainerNestedPath }; diff --git a/src/utils/cli-ui.ts b/src/utils/cli-ui.ts index 163bfd8..e5bb64a 100644 --- a/src/utils/cli-ui.ts +++ b/src/utils/cli-ui.ts @@ -2,6 +2,22 @@ import chalk from "chalk"; export function printError(message: string, component: string): void { console.error( - chalk.red(`\n\nšŸ˜ž ${message}:`, chalk.white(`[${component}]`)), + chalk.red(`${message}:`, chalk.bold(chalk.white(`[${component}] āŒ`))), + ); +} + +export async function printGenerateError(schematic: string, file: string) { + console.error( + " ", + chalk.redBright(`[${schematic}]`.padEnd(14)), + chalk.bold.white(`${file.split(".")[0]} not created! āŒ`), + ); +} + +export async function printGenerateSuccess(schematic: string, file: string) { + console.log( + " ", + chalk.greenBright(`[${schematic}]`.padEnd(14)), + chalk.bold.white(`${file.split(".")[0]} created! āœ”ļø`), ); } diff --git a/src/utils/verify-file-exists.ts b/src/utils/verify-file-exists.ts index 453c01d..97b66f5 100644 --- a/src/utils/verify-file-exists.ts +++ b/src/utils/verify-file-exists.ts @@ -1,24 +1,24 @@ import inquirer from "inquirer"; import fs from "node:fs"; -import { printError } from "./cli-ui"; +import { printError, printGenerateError } from "./cli-ui"; -async function verifyIfFileExists(path: string) { +async function verifyIfFileExists(path: string, schematic?: string) { const fileExists = fs.existsSync(path); + const fileName = path.split("/").pop(); if (fileExists) { const answer = await inquirer.prompt([ { type: "confirm", name: "confirm", - message: - "File with this path already exists. Do you want to create it anyway?", + message: `File [${fileName}] exists. Overwrite?`, default: true, }, ]); - - const fileName = path.split("/").pop(); if (!answer.confirm) { - printError("File not created!", fileName); + schematic + ? printGenerateError(schematic, fileName) + : printError("File not created!", fileName); process.exit(1); } }