From d5c7ab544222c5d8411f02becc3cf9051a5b9658 Mon Sep 17 00:00:00 2001 From: Exelo Date: Tue, 31 Mar 2026 05:59:03 +0200 Subject: [PATCH] feat: add component and system schematics --- e2e/component.test.ts | 171 ++++++++++++++++++ e2e/system.test.ts | 171 ++++++++++++++++++ src/collection.json | 10 + src/libs/component/component.factory.ts | 41 +++++ src/libs/component/component.options.d.ts | 16 ++ src/libs/component/component.schema.d.ts | 21 +++ .../files/js/__fileName__.component.js | 78 ++++++++ .../files/ts/__fileName__.component.ts | 61 +++++++ src/libs/component/schema.json | 30 +++ .../system/files/js/__fileName__.system.js | 44 +++++ .../system/files/ts/__fileName__.system.ts | 31 ++++ src/libs/system/schema.json | 30 +++ src/libs/system/system.factory.ts | 41 +++++ src/libs/system/system.options.d.ts | 16 ++ src/libs/system/system.schema.d.ts | 21 +++ src/utils/formatting.spec.ts | 72 +++++++- src/utils/formatting.ts | 32 +++- tsup.config.ts | 2 + 18 files changed, 882 insertions(+), 6 deletions(-) create mode 100644 e2e/component.test.ts create mode 100644 e2e/system.test.ts create mode 100644 src/libs/component/component.factory.ts create mode 100644 src/libs/component/component.options.d.ts create mode 100644 src/libs/component/component.schema.d.ts create mode 100644 src/libs/component/files/js/__fileName__.component.js create mode 100644 src/libs/component/files/ts/__fileName__.component.ts create mode 100644 src/libs/component/schema.json create mode 100644 src/libs/system/files/js/__fileName__.system.js create mode 100644 src/libs/system/files/ts/__fileName__.system.ts create mode 100644 src/libs/system/schema.json create mode 100644 src/libs/system/system.factory.ts create mode 100644 src/libs/system/system.options.d.ts create mode 100644 src/libs/system/system.schema.d.ts diff --git a/e2e/component.test.ts b/e2e/component.test.ts new file mode 100644 index 0000000..aaf3cf8 --- /dev/null +++ b/e2e/component.test.ts @@ -0,0 +1,171 @@ +import { SchematicTestRunner, type UnitTestTree } from "@angular-devkit/schematics/testing"; +import { resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const collectionPath = resolve(__dirname, "../dist/collection.json"); + +describe("component schematic", () => { + const runner = new SchematicTestRunner("schematics", collectionPath); + + describe("TypeScript component for client part", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("component", { + name: "myComponent", + directory: "my-app", + part: "client", + language: "ts", + }); + }); + + it("should generate a .ts component file", () => { + expect(tree.files).toContain("/my-app/my-component.component.ts"); + }); + + it("should use PascalCase class name", () => { + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("class MyComponentComponent"); + }); + + it("should use the correct part in the import", () => { + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("@nanoforge-dev/ecs-client"); + }); + + it("should export the component name as default", () => { + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("export default MyComponentComponent.name"); + }); + + it("should export the EDITOR_COMPONENT_MANIFEST", () => { + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("export const EDITOR_COMPONENT_MANIFEST"); + expect(content).toContain('name: "MyComponent"'); + }); + }); + + describe("TypeScript component for server part", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("component", { + name: "myComponent", + directory: "my-app", + part: "server", + language: "ts", + }); + }); + + it("should generate a .ts component file", () => { + expect(tree.files).toContain("/my-app/my-component.component.ts"); + }); + + it("should use the correct part in the import", () => { + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("@nanoforge-dev/ecs-server"); + }); + }); + + describe("JavaScript component", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("component", { + name: "myComponent", + directory: "my-app", + part: "client", + language: "js", + }); + }); + + it("should generate a .js component file", () => { + expect(tree.files).toContain("/my-app/my-component.component.js"); + }); + + it("should use PascalCase class name", () => { + const content = tree.readContent("/my-app/my-component.component.js"); + expect(content).toContain("class MyComponentComponent"); + }); + + it("should use the correct part in the JSDoc typedef", () => { + const content = tree.readContent("/my-app/my-component.component.js"); + expect(content).toContain("@nanoforge-dev/ecs-client"); + }); + + it("should export the EDITOR_COMPONENT_MANIFEST", () => { + const content = tree.readContent("/my-app/my-component.component.js"); + expect(content).toContain("export const EDITOR_COMPONENT_MANIFEST"); + expect(content).toContain('name: "MyComponent"'); + }); + }); + + describe("name formatting", () => { + it("should handle kebab-case name", async () => { + const tree = await runner.runSchematic("component", { + name: "my-component", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-component.component.ts"); + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("class MyComponentComponent"); + }); + + it("should handle PascalCase name", async () => { + const tree = await runner.runSchematic("component", { + name: "MyComponent", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-component.component.ts"); + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("class MyComponentComponent"); + }); + + it("should handle snake_case name", async () => { + const tree = await runner.runSchematic("component", { + name: "my_component", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-component.component.ts"); + const content = tree.readContent("/my-app/my-component.component.ts"); + expect(content).toContain("class MyComponentComponent"); + }); + }); + + describe("custom directory", () => { + it("should generate file in the specified directory", async () => { + const tree = await runner.runSchematic("component", { + name: "myComponent", + directory: "src/client/components", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/src/client/components/my-component.component.ts"); + }); + }); + + describe("default values", () => { + it("should default to TypeScript when language is not specified", async () => { + const tree = await runner.runSchematic("component", { + name: "myComponent", + directory: "my-app", + part: "client", + }); + expect(tree.files).toContain("/my-app/my-component.component.ts"); + }); + + it("should default to current directory when directory is not specified", async () => { + const tree = await runner.runSchematic("component", { + name: "myComponent", + part: "client", + }); + expect(tree.files).toContain("/my-component.component.ts"); + }); + }); +}); diff --git a/e2e/system.test.ts b/e2e/system.test.ts new file mode 100644 index 0000000..b392fc3 --- /dev/null +++ b/e2e/system.test.ts @@ -0,0 +1,171 @@ +import { SchematicTestRunner, type UnitTestTree } from "@angular-devkit/schematics/testing"; +import { resolve } from "node:path"; +import { beforeAll, describe, expect, it } from "vitest"; + +const collectionPath = resolve(__dirname, "../dist/collection.json"); + +describe("system schematic", () => { + const runner = new SchematicTestRunner("schematics", collectionPath); + + describe("TypeScript system for client part", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("system", { + name: "mySystem", + directory: "my-app", + part: "client", + language: "ts", + }); + }); + + it("should generate a .ts system file", () => { + expect(tree.files).toContain("/my-app/my-system.system.ts"); + }); + + it("should use camelCase function name", () => { + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export const mySystemSystem"); + }); + + it("should use the correct part in the import", () => { + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("@nanoforge-dev/ecs-client"); + }); + + it("should export the system name as default", () => { + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export default mySystemSystem.name"); + }); + + it("should export the EDITOR_SYSTEM_MANIFEST", () => { + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export const EDITOR_SYSTEM_MANIFEST"); + expect(content).toContain('name: "mySystem"'); + }); + }); + + describe("TypeScript system for server part", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("system", { + name: "mySystem", + directory: "my-app", + part: "server", + language: "ts", + }); + }); + + it("should generate a .ts system file", () => { + expect(tree.files).toContain("/my-app/my-system.system.ts"); + }); + + it("should use the correct part in the import", () => { + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("@nanoforge-dev/ecs-server"); + }); + }); + + describe("JavaScript system", () => { + let tree: UnitTestTree; + + beforeAll(async () => { + tree = await runner.runSchematic("system", { + name: "mySystem", + directory: "my-app", + part: "client", + language: "js", + }); + }); + + it("should generate a .js system file", () => { + expect(tree.files).toContain("/my-app/my-system.system.js"); + }); + + it("should use camelCase function name", () => { + const content = tree.readContent("/my-app/my-system.system.js"); + expect(content).toContain("export const mySystemSystem"); + }); + + it("should use the correct part in the JSDoc typedef", () => { + const content = tree.readContent("/my-app/my-system.system.js"); + expect(content).toContain("@nanoforge-dev/ecs-client"); + }); + + it("should export the EDITOR_SYSTEM_MANIFEST", () => { + const content = tree.readContent("/my-app/my-system.system.js"); + expect(content).toContain("export const EDITOR_SYSTEM_MANIFEST"); + expect(content).toContain('name: "mySystem"'); + }); + }); + + describe("name formatting", () => { + it("should handle kebab-case name", async () => { + const tree = await runner.runSchematic("system", { + name: "my-system", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-system.system.ts"); + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export const mySystemSystem"); + }); + + it("should handle PascalCase name", async () => { + const tree = await runner.runSchematic("system", { + name: "MySystem", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-system.system.ts"); + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export const mySystemSystem"); + }); + + it("should handle snake_case name", async () => { + const tree = await runner.runSchematic("system", { + name: "my_system", + directory: "my-app", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/my-app/my-system.system.ts"); + const content = tree.readContent("/my-app/my-system.system.ts"); + expect(content).toContain("export const mySystemSystem"); + }); + }); + + describe("custom directory", () => { + it("should generate file in the specified directory", async () => { + const tree = await runner.runSchematic("system", { + name: "mySystem", + directory: "src/client/systems", + part: "client", + language: "ts", + }); + expect(tree.files).toContain("/src/client/systems/my-system.system.ts"); + }); + }); + + describe("default values", () => { + it("should default to TypeScript when language is not specified", async () => { + const tree = await runner.runSchematic("system", { + name: "mySystem", + directory: "my-app", + part: "client", + }); + expect(tree.files).toContain("/my-app/my-system.system.ts"); + }); + + it("should default to current directory when directory is not specified", async () => { + const tree = await runner.runSchematic("system", { + name: "mySystem", + part: "client", + }); + expect(tree.files).toContain("/my-system.system.ts"); + }); + }); +}); diff --git a/src/collection.json b/src/collection.json index 697a4f5..19462ce 100644 --- a/src/collection.json +++ b/src/collection.json @@ -25,6 +25,16 @@ "factory": "./libs/docker/docker.factory#main", "description": "Create a Dockerfile for the application.", "schema": "./libs/docker/schema.json" + }, + "component": { + "factory": "./libs/component/component.factory#main", + "description": "Create a NanoForge component.", + "schema": "./libs/component/schema.json" + }, + "system": { + "factory": "./libs/system/system.factory#main", + "description": "Create a NanoForge system.", + "schema": "./libs/system/schema.json" } } } diff --git a/src/libs/component/component.factory.ts b/src/libs/component/component.factory.ts new file mode 100644 index 0000000..da8ba33 --- /dev/null +++ b/src/libs/component/component.factory.ts @@ -0,0 +1,41 @@ +import { type Path, join, normalize, strings } from "@angular-devkit/core"; +import { + type Rule, + type Source, + apply, + mergeWith, + move, + template, + url, +} from "@angular-devkit/schematics"; + +import { toKebabCase, toPascalCase } from "@utils/formatting"; + +import { type ComponentOptions } from "./component.options"; +import { type ComponentSchema } from "./component.schema"; + +const transform = (schema: ComponentSchema): ComponentOptions => { + return { + part: schema.part, + className: toPascalCase(schema.name), + fileName: toKebabCase(schema.name), + }; +}; + +const generate = (options: ComponentOptions, path: string, language: "ts" | "js"): Source => { + const rules = [ + template({ + ...strings, + ...options, + }), + move(normalize(path)), + ]; + + return apply(url(join("./files" as Path, language)), rules); +}; + +export const main = (schema: ComponentSchema): Rule => { + const options = transform(schema); + + return mergeWith(generate(options, schema.directory, schema.language)); +}; diff --git a/src/libs/component/component.options.d.ts b/src/libs/component/component.options.d.ts new file mode 100644 index 0000000..270628d --- /dev/null +++ b/src/libs/component/component.options.d.ts @@ -0,0 +1,16 @@ +export interface ComponentOptions { + /** + * The part of the application to generate + */ + part: "client" | "server"; + + /** + * Component class name + */ + className: string; + + /** + * Component filename + */ + fileName: string; +} diff --git a/src/libs/component/component.schema.d.ts b/src/libs/component/component.schema.d.ts new file mode 100644 index 0000000..4285128 --- /dev/null +++ b/src/libs/component/component.schema.d.ts @@ -0,0 +1,21 @@ +export interface ComponentSchema { + /** + * Component destination directory + */ + name: string; + + /** + * Component destination directory + */ + directory: string; + + /** + * The part of the application to generate + */ + part: "client" | "server"; + + /** + * NanoForge Application language + */ + language: "js" | "ts"; +} diff --git a/src/libs/component/files/js/__fileName__.component.js b/src/libs/component/files/js/__fileName__.component.js new file mode 100644 index 0000000..212ce6c --- /dev/null +++ b/src/libs/component/files/js/__fileName__.component.js @@ -0,0 +1,78 @@ +/** + * @typedef {import("@nanoforge-dev/ecs-<%= part %>").EditorComponentManifest} EditorComponentManifest + */ + +export class <%= className %>Component { + static name = "<%= className %>Component"; + name = this.constructor.name; + + paramA; + paramB; + paramC; + + /** + * <%= className %> component constructor + * @param {string} paramA + * @param {number} paramB + * @param {boolean} [paramC = false] + */ + constructor(paramA, paramB, paramC = false) { + this.paramA = paramA; + this.paramB = paramB; + this.paramC = paramC; + } + + get foo() { + return "bar"; + } + + get paramAOrDefault() { + return this.paramC ? this.paramA : "default"; + } + + addOne() { + this.paramB += 1; + } +} + +// * Required to generate code +export default <%= className %>Component.name; + +/** + * Editor component manifest + * * Required for the editor to display the component and generate code + * @type {EditorComponentManifest} + */ +export const EDITOR_COMPONENT_MANIFEST = { + name: "<%= className %>", + description: "<%= className %> component description", + params: [ + [ + { + type: "string", + name: "paramA", + description: "Param A description", + example: "Example value", + }, + ], + [ + { + type: "number", + name: "paramB", + description: "Param B description", + example: 3, + }, + ], + [ + { + type: "boolean", + name: "paramC", + description: "Param C description", + example: true, + default: false, + // Not required because it has a default value + optional: true, + }, + ], + ], +}; diff --git a/src/libs/component/files/ts/__fileName__.component.ts b/src/libs/component/files/ts/__fileName__.component.ts new file mode 100644 index 0000000..2b35828 --- /dev/null +++ b/src/libs/component/files/ts/__fileName__.component.ts @@ -0,0 +1,61 @@ +import { type EditorComponentManifest } from "@nanoforge-dev/ecs-<%= part %>"; + +export class <%= className %>Component { + name = this.constructor.name; + + constructor( + public paramA: string, + public paramB: number, + public paramC: boolean = false, + ) {} + + get foo() { + return "bar"; + } + + get paramAOrDefault() { + return this.paramC ? this.paramA : "default"; + } + + addOne() { + this.paramB += 1; + } +} + +// * Required to generate code +export default <%= className %>Component.name; + +// * Required for the editor to display the component and generate code +export const EDITOR_COMPONENT_MANIFEST: EditorComponentManifest = { + name: "<%= className %>", + description: "<%= className %> component description", + params: [ + [ + { + type: "string", + name: "paramA", + description: "Param A description", + example: "Example value", + }, + ], + [ + { + type: "number", + name: "paramB", + description: "Param B description", + example: 3, + }, + ], + [ + { + type: "boolean", + name: "paramC", + description: "Param C description", + example: true, + default: false, + // Not required because it has a default value + optional: true, + }, + ], + ], +}; diff --git a/src/libs/component/schema.json b/src/libs/component/schema.json new file mode 100644 index 0000000..a5a6d7f --- /dev/null +++ b/src/libs/component/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsNanoForgeComponent", + "title": "NanoForge Component Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Component name", + "default": "example" + }, + "directory": { + "type": "string", + "description": "Component destination directory", + "default": "." + }, + "part": { + "type": "string", + "enum": ["client", "server"], + "description": "The part of the application to generate" + }, + "language": { + "type": "string", + "enum": ["ts", "js"], + "description": "NanoForge application language", + "default": "ts" + } + }, + "required": ["part"] +} diff --git a/src/libs/system/files/js/__fileName__.system.js b/src/libs/system/files/js/__fileName__.system.js new file mode 100644 index 0000000..64240a7 --- /dev/null +++ b/src/libs/system/files/js/__fileName__.system.js @@ -0,0 +1,44 @@ +/** + * @typedef {import("@nanoforge-dev/common").Context} Context + * @typedef {import("@nanoforge-dev/ecs-<%= part %>").EditorSystemManifest} EditorSystemManifest + * @typedef {import("@nanoforge-dev/ecs-<%= part %>").Registry} Registry + */ + +import { ExampleComponent } from "../components/example.component"; + +/** + * <%= functionName %> system + * This system end the game when paramB reaches 0 for any entity with ExampleComponent + * @param {Registry} registry - ECS registry instance + * @param {Context} ctx - Nanoforge <%= part %> instance + */ +export const <%= functionName %>System = (registry, ctx) => { + const entities = registry.getZipper([ExampleComponent]); + + entities.forEach((entity) => { + if (entity.ExampleComponent.paramA === "end") { + ctx.app.setIsRunning(false); + return; + } + + if (entity.ExampleComponent.paramB === 0) entity.ExampleComponent.paramA = "end"; + + if (entity.ExampleComponent.paramB >= 0) + entity.ExampleComponent.paramB = entity.ExampleComponent.paramB - 1; + }); +}; + +// * Required to generate code +export default <%= functionName %>System.name; + +/** + * Editor component manifest + * * Required for the editor to display the system and generate code + * @type {EditorSystemManifest} + */ +export const EDITOR_SYSTEM_MANIFEST = { + name: "<%= functionName %>", + description: + "This system end the game when paramB reaches 0 for any entity with ExampleComponent", + dependencies: ["ExampleComponent"], +}; diff --git a/src/libs/system/files/ts/__fileName__.system.ts b/src/libs/system/files/ts/__fileName__.system.ts new file mode 100644 index 0000000..d1f9ce6 --- /dev/null +++ b/src/libs/system/files/ts/__fileName__.system.ts @@ -0,0 +1,31 @@ +import { type Context } from "@nanoforge-dev/common"; +import { type EditorSystemManifest, type Registry } from "@nanoforge-dev/ecs-<%= part %>"; + +import { ExampleComponent } from "../components/example.component"; + +export const <%= functionName %>System = (registry: Registry, ctx: Context) => { + const entities = registry.getZipper([ExampleComponent]); + + entities.forEach((entity) => { + if (entity.ExampleComponent.paramA === "end") { + ctx.app.setIsRunning(false); + return; + } + + if (entity.ExampleComponent.paramB === 0) entity.ExampleComponent.paramA = "end"; + + if (entity.ExampleComponent.paramB >= 0) + entity.ExampleComponent.paramB = entity.ExampleComponent.paramB - 1; + }); +}; + +// * Required to generate code +export default <%= functionName %>System.name; + +// * Required for the editor to display the system and generate code +export const EDITOR_SYSTEM_MANIFEST: EditorSystemManifest = { + name: "<%= functionName %>", + description: + "This system end the game when paramB reaches 0 for any entity with ExampleComponent", + dependencies: ["ExampleComponent"], +}; diff --git a/src/libs/system/schema.json b/src/libs/system/schema.json new file mode 100644 index 0000000..0578ac2 --- /dev/null +++ b/src/libs/system/schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "$id": "SchematicsNanoForgeSystem", + "title": "NanoForge System Options Schema", + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "System name", + "default": "example" + }, + "directory": { + "type": "string", + "description": "System destination directory", + "default": "." + }, + "part": { + "type": "string", + "enum": ["client", "server"], + "description": "The part of the application to generate" + }, + "language": { + "type": "string", + "enum": ["ts", "js"], + "description": "NanoForge application language", + "default": "ts" + } + }, + "required": ["part"] +} diff --git a/src/libs/system/system.factory.ts b/src/libs/system/system.factory.ts new file mode 100644 index 0000000..93fabde --- /dev/null +++ b/src/libs/system/system.factory.ts @@ -0,0 +1,41 @@ +import { type Path, join, normalize, strings } from "@angular-devkit/core"; +import { + type Rule, + type Source, + apply, + mergeWith, + move, + template, + url, +} from "@angular-devkit/schematics"; + +import { toCamelCase, toKebabCase } from "@utils/formatting"; + +import { type SystemOptions } from "./system.options"; +import { type SystemSchema } from "./system.schema"; + +const transform = (schema: SystemSchema): SystemOptions => { + return { + part: schema.part, + functionName: toCamelCase(schema.name), + fileName: toKebabCase(schema.name), + }; +}; + +const generate = (options: SystemOptions, path: string, language: "ts" | "js"): Source => { + const rules = [ + template({ + ...strings, + ...options, + }), + move(normalize(path)), + ]; + + return apply(url(join("./files" as Path, language)), rules); +}; + +export const main = (schema: SystemSchema): Rule => { + const options = transform(schema); + + return mergeWith(generate(options, schema.directory, schema.language)); +}; diff --git a/src/libs/system/system.options.d.ts b/src/libs/system/system.options.d.ts new file mode 100644 index 0000000..c16d2e6 --- /dev/null +++ b/src/libs/system/system.options.d.ts @@ -0,0 +1,16 @@ +export interface SystemOptions { + /** + * The part of the application to generate + */ + part: "client" | "server"; + + /** + * System function name + */ + functionName: string; + + /** + * System filename + */ + fileName: string; +} diff --git a/src/libs/system/system.schema.d.ts b/src/libs/system/system.schema.d.ts new file mode 100644 index 0000000..19c7bfe --- /dev/null +++ b/src/libs/system/system.schema.d.ts @@ -0,0 +1,21 @@ +export interface SystemSchema { + /** + * System destination directory + */ + name: string; + + /** + * System destination directory + */ + directory: string; + + /** + * The part of the application to generate + */ + part: "client" | "server"; + + /** + * NanoForge Application language + */ + language: "js" | "ts"; +} diff --git a/src/utils/formatting.spec.ts b/src/utils/formatting.spec.ts index 6318fa4..dfaf4b4 100644 --- a/src/utils/formatting.spec.ts +++ b/src/utils/formatting.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { toKebabCase } from "./formatting"; +import { toCamelCase, toKebabCase, toPascalCase } from "./formatting"; describe("toKebabCase", () => { it("should convert camelCase to kebab-case", () => { @@ -8,7 +8,7 @@ describe("toKebabCase", () => { }); it("should convert PascalCase to kebab-case", () => { - expect(toKebabCase("MyVariableName")).toBe("-my-variable-name"); + expect(toKebabCase("MyVariableName")).toBe("my-variable-name"); }); it("should convert spaces to hyphens", () => { @@ -31,3 +31,71 @@ describe("toKebabCase", () => { expect(toKebabCase("")).toBe(""); }); }); + +describe("toPascalCase", () => { + it("should convert camelCase to PascalCase", () => { + expect(toPascalCase("myVariableName")).toBe("MyVariableName"); + }); + + it("should convert kebab-case to PascalCase", () => { + expect(toPascalCase("my-variable-name")).toBe("MyVariableName"); + }); + + it("should convert snake_case to PascalCase", () => { + expect(toPascalCase("my_variable_name")).toBe("MyVariableName"); + }); + + it("should convert spaces to PascalCase", () => { + expect(toPascalCase("my variable name")).toBe("MyVariableName"); + }); + + it("should handle already PascalCase string", () => { + expect(toPascalCase("MyVariableName")).toBe("MyVariableName"); + }); + + it("should handle a single word", () => { + expect(toPascalCase("hello")).toBe("Hello"); + }); + + it("should handle an empty string", () => { + expect(toPascalCase("")).toBe(""); + }); + + it("should handle SCREAMING_SNAKE_CASE", () => { + expect(toPascalCase("MY_VARIABLE_NAME")).toBe("MyVariableName"); + }); +}); + +describe("toCamelCase", () => { + it("should convert kebab-case to camelCase", () => { + expect(toCamelCase("my-variable-name")).toBe("myVariableName"); + }); + + it("should convert snake_case to camelCase", () => { + expect(toCamelCase("my_variable_name")).toBe("myVariableName"); + }); + + it("should convert spaces to camelCase", () => { + expect(toCamelCase("my variable name")).toBe("myVariableName"); + }); + + it("should handle already camelCase string", () => { + expect(toCamelCase("myVariableName")).toBe("myVariableName"); + }); + + it("should convert PascalCase to camelCase", () => { + expect(toCamelCase("MyVariableName")).toBe("myVariableName"); + }); + + it("should handle a single word", () => { + expect(toCamelCase("hello")).toBe("hello"); + }); + + it("should handle an empty string", () => { + expect(toCamelCase("")).toBe(""); + }); + + it("should handle SCREAMING_SNAKE_CASE", () => { + expect(toCamelCase("MY_VARIABLE_NAME")).toBe("myVariableName"); + }); +}); diff --git a/src/utils/formatting.ts b/src/utils/formatting.ts index 679c269..d86154e 100644 --- a/src/utils/formatting.ts +++ b/src/utils/formatting.ts @@ -1,6 +1,30 @@ -export const toKebabCase = (str: string): string => { +const toWords = (str: string): string[] => { return str - .replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, "$1-$2") - .replace(/[\s_]+/g, "-") - .toLowerCase(); + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2") + .replace(/[-_]+/g, " ") + .trim() + .split(/\s+/) + .filter(Boolean); +}; + +export const toKebabCase = (str: string): string => { + return toWords(str) + .map((word) => word.toLowerCase()) + .join("-"); +}; + +export const toPascalCase = (str: string): string => { + return toWords(str) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); +}; + +export const toCamelCase = (str: string): string => { + const words = toWords(str); + return words + .map((word, i) => + i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join(""); }; diff --git a/tsup.config.ts b/tsup.config.ts index 16ddd2f..5469bd6 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -63,4 +63,6 @@ export default [ createLibTsupConfig("part-base"), createLibTsupConfig("part-main"), createLibTsupConfig("docker"), + createLibTsupConfig("component"), + createLibTsupConfig("system"), ];