From d298053651bf784db6e59d3756a8c87d984b3477 Mon Sep 17 00:00:00 2001 From: Bakhtiyor Nigmatov Date: Fri, 17 Oct 2025 14:53:47 +0500 Subject: [PATCH] feat: add NestJS JSON schema component generator --- README.md | 24 +- package.json | 27 +- src/schema-generator/example.schema.json | 37 +++ src/schema-generator/main.ts | 24 ++ .../schema-generator.module.ts | 8 + .../schema-generator.service.ts | 282 ++++++++++++++++++ tsconfig.json | 18 ++ 7 files changed, 407 insertions(+), 13 deletions(-) create mode 100644 src/schema-generator/example.schema.json create mode 100644 src/schema-generator/main.ts create mode 100644 src/schema-generator/schema-generator.module.ts create mode 100644 src/schema-generator/schema-generator.service.ts create mode 100644 tsconfig.json diff --git a/README.md b/README.md index 8bb67fc..5292a6f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,25 @@ # angular-load-dynamic-components -[Edit on StackBlitz ⚡️](https://stackblitz.com/edit/angular-load-dynamic-components) \ No newline at end of file +## JSON Schema form component generator + +This repository now includes a NestJS-based CLI utility that converts a JSON Schema description of a form into an Angular component written with Angular Material controls. The generator creates two files: `result.component.ts` and `result.component.html`. + +### Usage + +1. Install dependencies: + + ```bash + npm install + ``` + +2. Provide a JSON Schema file (an example is available at `src/schema-generator/example.schema.json`). + +3. Run the generator, passing the schema path and an optional output directory: + + ```bash + npm run schema:generate -- src/schema-generator/example.schema.json src/app/generated + ``` + + The command above will create `result.component.ts` and `result.component.html` inside `src/app/generated`. + +Both generated files are ready to be integrated into an Angular module that imports the necessary Angular Material and ReactiveForms modules. \ No newline at end of file diff --git a/package.json b/package.json index 63ed65b..6e8d9e3 100644 --- a/package.json +++ b/package.json @@ -3,25 +3,28 @@ "version": "0.0.0", "private": true, "dependencies": { - "rxjs": "6.5.3", - "tslib": "1.10.0", - "core-js": "2.6.11", - "zone.js": "0.9.1", - "jasmine-core": "2.99.1", - "@angular/core": "8.2.14", - "@angular/forms": "8.2.14", + "@angular/animations": "8.2.14", "@angular/common": "8.2.14", - "@angular/router": "8.2.14", - "jasmine-marbles": "0.6.0", "@angular/compiler": "8.2.14", - "web-animations-js": "2.3.2", - "@angular/animations": "8.2.14", + "@angular/core": "8.2.14", + "@angular/forms": "8.2.14", "@angular/platform-browser": "8.2.14", + "@angular/platform-browser-dynamic": "8.2.14", + "@angular/router": "8.2.14", + "@nestjs/common": "^7.6.18", + "@nestjs/core": "^7.6.18", "angular-in-memory-web-api": "0.8.0", - "@angular/platform-browser-dynamic": "8.2.14" + "core-js": "2.6.11", + "jasmine-core": "2.99.1", + "jasmine-marbles": "0.6.0", + "reflect-metadata": "^0.1.13", + "rxjs": "6.5.3", + "tslib": "1.10.0", + "zone.js": "0.9.1" }, "scripts": { "ng": "ng", + "schema:generate": "ts-node src/schema-generator/main.ts", "start": "ng serve", "build": "ng build", "test": "ng test", diff --git a/src/schema-generator/example.schema.json b/src/schema-generator/example.schema.json new file mode 100644 index 0000000..47e5b12 --- /dev/null +++ b/src/schema-generator/example.schema.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "User form", + "description": "Collect base information about the user", + "type": "object", + "required": ["firstName", "email", "acceptTerms"], + "properties": { + "firstName": { + "type": "string", + "title": "First name", + "minLength": 2 + }, + "age": { + "type": "number", + "title": "Age", + "minimum": 0 + }, + "email": { + "type": "string", + "title": "Email" + }, + "favoriteColor": { + "type": "string", + "title": "Favorite color", + "enum": ["Red", "Green", "Blue"] + }, + "birthday": { + "type": "string", + "format": "date", + "title": "Birthday" + }, + "acceptTerms": { + "type": "boolean", + "title": "I accept the terms and conditions" + } + } +} diff --git a/src/schema-generator/main.ts b/src/schema-generator/main.ts new file mode 100644 index 0000000..2f43269 --- /dev/null +++ b/src/schema-generator/main.ts @@ -0,0 +1,24 @@ +import 'reflect-metadata'; +import { Logger } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { SchemaGeneratorModule } from './schema-generator.module'; +import { SchemaGeneratorService } from './schema-generator.service'; + +async function bootstrap() { + const appContext = await NestFactory.createApplicationContext(SchemaGeneratorModule, { + logger: ['error', 'warn'], + }); + + try { + const service = appContext.get(SchemaGeneratorService); + await service.run(process.argv.slice(2)); + Logger.log('Angular component files were generated successfully.', 'SchemaGenerator'); + } catch (error) { + Logger.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + } finally { + await appContext.close(); + } +} + +bootstrap(); diff --git a/src/schema-generator/schema-generator.module.ts b/src/schema-generator/schema-generator.module.ts new file mode 100644 index 0000000..5b673b7 --- /dev/null +++ b/src/schema-generator/schema-generator.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { SchemaGeneratorService } from './schema-generator.service'; + +@Module({ + providers: [SchemaGeneratorService], + exports: [SchemaGeneratorService], +}) +export class SchemaGeneratorModule {} diff --git a/src/schema-generator/schema-generator.service.ts b/src/schema-generator/schema-generator.service.ts new file mode 100644 index 0000000..51739d4 --- /dev/null +++ b/src/schema-generator/schema-generator.service.ts @@ -0,0 +1,282 @@ +import { Injectable } from '@nestjs/common'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +interface JsonSchemaProperty { + type?: string | string[]; + title?: string; + description?: string; + enum?: Array; + format?: string; + default?: unknown; + minimum?: number; + maximum?: number; + minLength?: number; + maxLength?: number; + items?: JsonSchemaProperty; +} + +interface JsonSchema { + title?: string; + description?: string; + type?: string; + properties?: Record; + required?: string[]; +} + +@Injectable() +export class SchemaGeneratorService { + async run(args: string[]): Promise { + const [schemaPath, outputDir = '.'] = args; + + if (!schemaPath) { + throw new Error('Path to the JSON schema file is required.'); + } + + const schemaContent = await fs.readFile(schemaPath, 'utf8'); + const schema = JSON.parse(schemaContent) as JsonSchema; + + const componentClassName = 'ResultComponent'; + const componentSelector = 'app-result'; + + const required = new Set(schema.required ?? []); + const properties = schema.properties ?? {}; + + const tsContent = this.createComponentTs(componentClassName, componentSelector, properties, required, schema.title); + const htmlContent = this.createComponentHtml(properties, required, schema.title, schema.description); + + await fs.mkdir(outputDir, { recursive: true }); + await fs.writeFile(path.join(outputDir, 'result.component.ts'), tsContent, 'utf8'); + await fs.writeFile(path.join(outputDir, 'result.component.html'), htmlContent, 'utf8'); + } + + private createComponentTs( + className: string, + selector: string, + properties: Record, + required: Set, + schemaTitle?: string, + ): string { + const formControls = Object.entries(properties) + .map(([key, property]) => this.createFormControl(key, property, required)) + .join(',\n '); + + const controlGetters = Object.keys(properties) + .map((key) => this.createControlGetter(key)) + .join('\n\n '); + + const lines: string[] = [ + "import { ChangeDetectionStrategy, Component } from '@angular/core';", + "import { FormBuilder, Validators } from '@angular/forms';", + '', + '@Component({', + ` selector: '${selector}',`, + " templateUrl: './result.component.html',", + ' changeDetection: ChangeDetectionStrategy.OnPush,', + '})', + `export class ${className} {`, + schemaTitle ? ` readonly title = ${JSON.stringify(schemaTitle)};` : '', + ' readonly form = this.fb.group({', + formControls, + ' });', + '', + ' constructor(private readonly fb: FormBuilder) {}', + '', + ' submit(): void {', + ' if (this.form.invalid) {', + ' this.form.markAllAsTouched();', + ' return;', + ' }', + '', + ' console.log(this.form.value);', + ' }', + controlGetters ? ` + ${controlGetters} +` : '', + '}', + '', + ].filter(Boolean) as string[]; + + return lines.join('\n'); + } + + private createControlGetter(key: string): string { + const camelKey = this.toCamel(key); + return `get ${camelKey}Control() {\n return this.form.get('${key}');\n }`; + } + + private createFormControl(key: string, property: JsonSchemaProperty, required: Set): string { + const validators: string[] = []; + const valueType = this.resolvePropertyType(property); + + if (required.has(key)) { + validators.push('Validators.required'); + } + + if (property.type === 'string' || property.format === 'date') { + if (typeof property.minLength === 'number') { + validators.push(`Validators.minLength(${property.minLength})`); + } + if (typeof property.maxLength === 'number') { + validators.push(`Validators.maxLength(${property.maxLength})`); + } + } + + if (valueType === 'number' || valueType === 'integer') { + if (typeof property.minimum === 'number') { + validators.push(`Validators.min(${property.minimum})`); + } + if (typeof property.maximum === 'number') { + validators.push(`Validators.max(${property.maximum})`); + } + } + + const defaultValue = this.getDefaultValue(property, valueType); + + const validatorsSnippet = validators.length ? `, [${validators.join(', ')}]` : ''; + return `'${key}': this.fb.control(${defaultValue}${validatorsSnippet})`; + } + + private resolvePropertyType(property: JsonSchemaProperty): string | undefined { + if (Array.isArray(property.type)) { + return property.type[0]; + } + if (!property.type && property.enum) { + return typeof property.enum[0]; + } + return property.type; + } + + private getDefaultValue(property: JsonSchemaProperty, valueType?: string): string { + if (property.default !== undefined) { + return JSON.stringify(property.default); + } + + switch (valueType) { + case 'boolean': + return 'false'; + case 'number': + case 'integer': + return 'null'; + case 'array': + return '[]'; + default: + return "''"; + } + } + + private createComponentHtml( + properties: Record, + required: Set, + schemaTitle?: string, + schemaDescription?: string, + ): string { + const fieldsMarkup = Object.entries(properties) + .map(([key, property]) => this.renderField(key, property, required)) + .join('\n\n'); + + const lines: string[] = [ + '
', + schemaTitle ? `

${schemaTitle}

` : '', + schemaDescription ? `

${schemaDescription}

` : '', + fieldsMarkup.split('\n').map((line) => (line ? ` ${line}` : '')).join('\n'), + '', + '
', + ' ', + '
', + '
', + '', + ].filter(Boolean) as string[]; + + return lines.join('\n'); + } + + private renderField(key: string, property: JsonSchemaProperty, required: Set): string { + const type = this.resolvePropertyType(property); + const label = property.title ?? this.toTitle(key); + const description = property.description ? `\n ${property.description}` : ''; + const isRequired = required.has(key); + + if (property.enum && property.enum.length) { + const options = property.enum + .map((value) => ` ${value}`) + .join('\n'); + + return [ + '', + ` ${label}`, + ` `, + options, + ' ', + description, + isRequired ? this.requiredErrorTemplate(key, label) : '', + '', + ] + .filter(Boolean) + .join('\n'); + } + + switch (type) { + case 'boolean': + return [ + '', + ` ${label}${isRequired ? ' *' : ''}`, + '', + ].join('\n'); + case 'number': + case 'integer': + return this.renderInputField(key, label, 'number', isRequired, description); + case 'string': + if (property.format === 'date') { + return this.renderInputField(key, label, 'date', isRequired, description); + } + return this.renderInputField(key, label, 'text', isRequired, description); + case 'array': + return this.renderInputField(key, label, 'text', isRequired, description, { + hint: 'Provide values separated by comma', + }); + default: + return this.renderInputField(key, label, 'text', isRequired, description); + } + } + + private renderInputField( + key: string, + label: string, + type: string, + required: boolean, + description: string, + options: { hint?: string } = {}, + ): string { + const hint = options.hint ? `\n ${options.hint}` : ''; + + return [ + '', + ` ${label}`, + ` `, + description, + hint, + required ? this.requiredErrorTemplate(key, label) : '', + '', + ] + .filter(Boolean) + .join('\n'); + } + + private requiredErrorTemplate(key: string, label: string): string { + return ` ${label} is required`; + } + + private toCamel(value: string): string { + return value + .replace(/[-_\s]+(.)?/g, (_, c) => (c ? c.toUpperCase() : '')) + .replace(/^(.)/, (match) => match.toLowerCase()); + } + + private toTitle(value: string): string { + const withSpaces = value + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/[-_]+/g, ' '); + return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1); + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..da136db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "commonjs", + "moduleResolution": "node", + "declaration": false, + "sourceMap": false, + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +}