import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; // Always resolve repo root relative to this script location // /generation/generate.mjs -> root is parent folder of generation/ const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const ROOT = path.resolve(__dirname, '..'); function readFile(p) { return fs.readFileSync(p, 'utf8'); } function writeFile(p, content) { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, content, 'utf8'); } function toKebab(s) { return s .replace(/([a-z0-9])([A-Z])/g, '$1-$2') .replace(/_/g, '-') .toLowerCase(); } function pluralize(resource) { // Minimal heuristic; can be improved later. if (resource === 'equipment') return 'equipment'; if (resource.endsWith('s')) return `${resource}es`; return `${resource}s`; } function upperFirst(s) { return s ? s[0].toUpperCase() + s.slice(1) : s; } function lowerFirst(s) { return s ? s[0].toLowerCase() + s.slice(1) : s; } function toIdentifierFromKebab(kebab) { // "repair-order" -> "repairOrder" return kebab.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); } function parseBlocks(text, kind) { // kind: 'enum' | 'entity' const blocks = []; const lines = text.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const m = line.match(new RegExp(`^\\s*${kind}\\s+([A-Za-z_][A-Za-z0-9_]*)\\s*\\{\\s*$`)); if (!m) continue; const name = m[1]; let depth = 0; const start = i; let end = i; for (let j = i; j < lines.length; j++) { const l = lines[j]; if (l.includes('{')) depth += (l.match(/\{/g) || []).length; if (l.includes('}')) depth -= (l.match(/\}/g) || []).length; if (depth === 0 && j > i) { end = j; break; } } const body = lines.slice(start + 1, end).join('\n'); blocks.push({ name, body, startLine: start, endLine: end }); i = end; } return blocks; } function parseEnum(body) { const values = []; const labels = {}; const re = /value\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{([\s\S]*?)\}/gm; let m; while ((m = re.exec(body))) { values.push(m[1]); const label = (m[2].match(/label\s+"([^"]+)"/m) || [])[1]; labels[m[1]] = label || m[1]; } return { values, labels }; } function parseEntity(body) { const attrs = []; const entityLabel = (body.match(/description\s+"([^"]+)"/m) || [])[1]; const lines = body.split(/\r?\n/); for (let i = 0; i < lines.length; i++) { const m = lines[i].match(/^\s*attribute\s+([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$/); if (!m) continue; const name = m[1]; let depth = 0; const start = i; let end = i; for (let j = i; j < lines.length; j++) { const l = lines[j]; if (l.includes('{')) depth += (l.match(/\{/g) || []).length; if (l.includes('}')) depth -= (l.match(/\}/g) || []).length; if (depth === 0 && j > i) { end = j; break; } } const abody = lines.slice(start + 1, end).join('\n'); const type = (abody.match(/^\s*type\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1]; const isRequired = /^\s*is required\s*;/m.test(abody); const isUnique = /^\s*is unique\s*;/m.test(abody); const isPrimary = /^\s*key primary\s*;/m.test(abody); const defaultValue = (abody.match(/^\s*default\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || [])[1]; const foreignRel = (abody.match(/relates\s+([A-Za-z_][A-Za-z0-9_]*)\.([A-Za-z_][A-Za-z0-9_]*)\s*;/m) || []).slice(1); const description = (abody.match(/description\s+"([^"]+)"/m) || [])[1]; attrs.push({ name, type, label: description || name, isRequired, isUnique, isPrimary, defaultValue, foreign: foreignRel.length ? { entity: foreignRel[0], field: foreignRel[1] } : null, }); i = end; } const pk = attrs.find((a) => a.isPrimary); if (!pk) throw new Error('Entity missing primary key attribute'); return { attributes: attrs, primaryKey: pk.name, label: entityLabel }; } function parseDomainDSL(dslText) { const enums = {}; const entities = {}; for (const b of parseBlocks(dslText, 'enum')) { enums[b.name] = parseEnum(b.body); } for (const b of parseBlocks(dslText, 'entity')) { entities[b.name] = parseEntity(b.body); } return { enums, entities }; } function getEntityAttrNames(entity) { return new Set(entity.attributes.map((a) => a.name)); } function getBestSortField(entity, pk) { const attrs = getEntityAttrNames(entity); if (attrs.has('inventoryNumber')) return 'inventoryNumber'; if (attrs.has('number')) return 'number'; if (attrs.has('code')) return 'code'; if (attrs.has('name')) return 'name'; return pk; } function getReferenceDisplayExpr(foreignEntity) { const attrs = getEntityAttrNames(foreignEntity); if (attrs.has('inventoryNumber')) { return "(record) => record.inventoryNumber ? `${record.inventoryNumber} — ${record.name ?? record.inventoryNumber}` : (record.name ?? record.id)"; } if (attrs.has('code')) { return "(record) => record.code ? `${record.code} — ${record.name ?? record.code}` : (record.name ?? record.id)"; } if (attrs.has('number')) { return "(record) => record.number ? `${record.number} — ${record.name ?? record.number}` : (record.name ?? record.id)"; } if (attrs.has('name')) { return "(record) => record.name ?? record.id"; } return "(record) => record.id"; } function getAttributeLabel(attr, allEntities) { if (attr.label && attr.label !== attr.name) return attr.label; if (attr.name === 'status') return 'Статус'; if (attr.name === 'equipmentId') return 'Оборудование'; if (attr.name === 'equipmentTypeCode') return 'Вид оборудования'; if (attr.foreign) return allEntities[attr.foreign.entity]?.label || attr.name; return attr.name; } function prismaScalarType(dslType) { switch (dslType) { case 'string': case 'text': return 'String'; case 'uuid': return 'String'; case 'integer': return 'Int'; case 'decimal': return 'Decimal'; case 'date': return 'DateTime'; default: // enum type name return dslType; } } function generatePrismaEnum(name, values) { return `enum ${name} {\n${values.map((v) => ` ${v}`).join('\n')}\n}\n`; } function generatePrismaModel(name, entity, allEntities) { const lines = []; lines.push(`model ${name} {`); for (const attr of entity.attributes) { if (attr.foreign) { // Keep scalar FK field, relation field added below } const scalar = prismaScalarType(attr.type); const optional = attr.isRequired || attr.isPrimary ? '' : '?'; const parts = [` ${attr.name} ${scalar}${optional}`]; if (attr.isPrimary) { if (attr.type === 'uuid' || attr.name === 'id') parts.push('@id @default(uuid())'); else parts.push('@id'); } if (attr.isUnique && !attr.isPrimary) parts.push('@unique'); if (attr.defaultValue) parts.push(`@default(${attr.defaultValue})`); lines.push(`${parts.join(' ')}`); } // Add relations for foreign keys for (const attr of entity.attributes.filter((a) => a.foreign)) { const relEntity = attr.foreign.entity; const relField = attr.foreign.field; const relName = lowerFirst(relEntity); // relation field must not collide; fallback to relEntity name if needed const relationFieldName = entity.attributes.some((a) => a.name === relName) ? `${relName}Ref` : relName; lines.push( ` ${relationFieldName} ${relEntity} @relation(fields: [${attr.name}], references: [${relField}])` ); } // Add back-relations (required by Prisma when a relation field exists) // For each other entity that has a FK pointing to this model, create a list field. for (const [otherName, otherEntity] of Object.entries(allEntities)) { for (const fk of otherEntity.attributes.filter((a) => a.foreign)) { if (fk.foreign.entity !== name) continue; const candidate = pluralize(lowerFirst(otherName)); const fieldName = lines.some((l) => l.startsWith(` ${candidate} `)) ? `${candidate}List` : candidate; // Avoid duplicates if multiple FKs exist (basic de-dupe) if (lines.some((l) => l.startsWith(` ${fieldName} `))) continue; lines.push(` ${fieldName} ${otherName}[]`); } } lines.push('}'); return `${lines.join('\n')}\n`; } function ensurePrismaSchema({ enums, entities }, prismaPath, apply) { const existing = fs.existsSync(prismaPath) ? readFile(prismaPath) : ''; const hasGenerator = /generator\s+client\s*\{/m.test(existing); const header = hasGenerator ? existing.split(/\n(?=enum|model)\b/)[0].trimEnd() + '\n\n' : `generator client {\n provider = "prisma-client-js"\n}\n\ndatasource db {\n provider = "postgresql"\n url = env("DATABASE_URL")\n}\n\n`; const out = [header]; // Preserve existing enum/model blocks not in DSL? For now, regenerate from DSL only. for (const [name, e] of Object.entries(enums)) out.push(generatePrismaEnum(name, e.values) + '\n'); for (const [name, ent] of Object.entries(entities)) out.push(generatePrismaModel(name, ent, entities) + '\n'); const next = out.join('').trimEnd() + '\n'; if (!apply) return { changed: next !== existing, content: next }; writeFile(prismaPath, next); return { changed: true, content: next }; } function renderBackendModule(entityName, entity, resourceName, pk) { const className = entityName; const moduleName = `${className}Module`; const serviceName = `${className}Service`; const controllerName = `${className}Controller`; const folder = toKebab(entityName); // DTOs const enumTypes = entity.attributes .filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) .map((a) => a.type); const enumUnion = (typeName) => { // Unknown labels in DSL aren't needed; keep as string union to avoid importing Prisma enums. return `'${typeName}'`; }; const dtoType = (attr) => { switch (attr.type) { case 'uuid': case 'string': case 'text': return 'string'; case 'integer': return 'number'; case 'decimal': return 'string'; case 'date': return 'string'; default: // enum return 'string'; } }; const createDtoLines = []; createDtoLines.push(`export class Create${className}Dto {`); for (const a of entity.attributes) { if (a.isPrimary && a.type === 'uuid') continue; // generated const opt = a.isRequired && !(a.isPrimary && a.type !== 'uuid') ? '!' : '?'; createDtoLines.push(` ${a.name}${opt}: ${dtoType(a)};`); } createDtoLines.push('}'); const updateDtoLines = []; updateDtoLines.push(`export class Update${className}Dto {`); if (pk !== 'id') updateDtoLines.push(` id?: string;`); for (const a of entity.attributes) { if (pk !== 'id' && a.name === 'id') continue; updateDtoLines.push(` ${a.name}?: ${dtoType(a)};`); } updateDtoLines.push('}'); const controller = `import { Controller, Get, Post, Patch, Delete, Param, Body, Query, Res } from '@nestjs/common';\nimport { Response } from 'express';\nimport { ${serviceName} } from './${folder}.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\n@Controller('${resourceName}')\nexport class ${controllerName} {\n constructor(private readonly service: ${serviceName}) {}\n\n @Get()\n async findAll(@Query() query: any, @Res() res: Response) {\n const result = await this.service.findAll(query);\n res.set('Content-Range', \`${resourceName} \${query._start || 0}-\${query._end || result.total}/\${result.total}\`);\n res.set('Access-Control-Expose-Headers', 'Content-Range');\n return res.json(result.data);\n }\n\n @Get(':${pk}')\n findOne(@Param('${pk}') id: string) {\n return this.service.findOne(id);\n }\n\n @Post()\n create(@Body() dto: Create${className}Dto) {\n return this.service.create(dto);\n }\n\n @Patch(':${pk}')\n update(@Param('${pk}') id: string, @Body() dto: Update${className}Dto) {\n return this.service.update(id, dto);\n }\n\n @Delete(':${pk}')\n remove(@Param('${pk}') id: string) {\n return this.service.remove(id);\n }\n}\n`; const service = `import { Injectable } from '@nestjs/common';\nimport { Prisma } from '@prisma/client';\nimport { PrismaService } from '../../prisma/prisma.service';\nimport { Create${className}Dto } from './dto/create-${folder}.dto';\nimport { Update${className}Dto } from './dto/update-${folder}.dto';\n\nfunction serializeRecord(record: any) {\n return {\n ...record,\n${entity.attributes .filter((a) => a.type === 'decimal') .map((a) => ` ${a.name}: record.${a.name}?.toString() ?? null,`) .join('\n')}\n${entity.attributes .filter((a) => a.type === 'date') .map((a) => ` ${a.name}: record.${a.name}?.toISOString() ?? null,`) .join('\n')}\n };\n}\n\n@Injectable()\nexport class ${serviceName} {\n constructor(private readonly prisma: PrismaService) {}\n\n async findAll(query: { _start?: string; _end?: string; _sort?: string; _order?: string; [key: string]: any }) {\n const start = parseInt(query._start) || 0;\n const end = parseInt(query._end) || 10;\n const take = end - start;\n const skip = start;\n const sortField = query._sort || '${getBestSortField(entity, pk)}';\n const sortOrder = (query._order || 'ASC').toLowerCase() as 'asc' | 'desc';\n\n const where: any = {};\n\n if (query.q) {\n const q = String(query.q);\n const ors: any[] = [];\n ${entity.attributes .filter((a) => ['string', 'text'].includes(a.type)) .slice(0, 6) .map((a) => `ors.push({ ${a.name}: { contains: q, mode: 'insensitive' } });`) .join('\n ')}\n if (ors.length) where.OR = ors;\n }\n\n ${entity.attributes .filter((a) => ['string', 'text'].includes(a.type) && !a.foreign) .map((a) => `if (query.${a.name}) where.${a.name} = { contains: query.${a.name}, mode: 'insensitive' };`) .join('\n ')}\n\n ${entity.attributes .filter((a) => a.foreign) .map((a) => `if (query.${a.name}) where.${a.name} = query.${a.name};`) .join('\n ')}\n\n // Enum multi-value support (e.g. status=A&status=B)\n ${entity.attributes .filter((a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) .map((a) => `if (query.${a.name}) { const vals = Array.isArray(query.${a.name}) ? query.${a.name} : [query.${a.name}]; where.${a.name} = vals.length > 1 ? { in: vals } : vals[0]; }`) .join('\n ')}\n\n if (query.id) {\n const ids = Array.isArray(query.id) ? query.id : [query.id];\n where.${pk} = { in: ids };\n }\n\n const [data, total] = await Promise.all([\n this.prisma.${lowerFirst(className)}.findMany({ where, skip, take, orderBy: { [sortField]: sortOrder } }),\n this.prisma.${lowerFirst(className)}.count({ where }),\n ]);\n\n const mapped = ${pk === 'id' ? 'data.map(serializeRecord)' : `data.map((r: any) => ({ id: r.${pk}, ...serializeRecord(r) }))`};\n return { data: mapped, total };\n }\n\n async findOne(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.findUniqueOrThrow({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async create(dto: Create${className}Dto) {\n const data: any = { ...(dto as any) };\n${entity.attributes .filter((a) => a.type === 'date') .map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`) .join('\n')}\n${entity.attributes .filter((a) => a.type === 'decimal') .map((a) => ` if (data.${a.name}) data.${a.name} = new Prisma.Decimal(data.${a.name});`) .join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.create({ data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async update(id: string, dto: Update${className}Dto) {\n const data: any = { ...(dto as any) };\n delete data.id;\n delete data.${pk};\n${entity.attributes .filter((a) => a.type === 'date') .map((a) => ` if (data.${a.name}) data.${a.name} = new Date(data.${a.name});`) .join('\n')}\n${entity.attributes .filter((a) => a.type === 'decimal') .map((a) => ` if (data.${a.name} !== undefined && data.${a.name} !== null) data.${a.name} = new Prisma.Decimal(data.${a.name});`) .join('\n')}\n\n const record = await this.prisma.${lowerFirst(className)}.update({ where: { ${pk}: id } as any, data });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n\n async remove(id: string) {\n const record = await this.prisma.${lowerFirst(className)}.delete({ where: { ${pk}: id } as any });\n return ${pk === 'id' ? 'serializeRecord(record)' : `{ id: (record as any).${pk}, ...serializeRecord(record) }`};\n }\n}\n`; const mod = `import { Module } from '@nestjs/common';\nimport { ${controllerName} } from './${folder}.controller';\nimport { ${serviceName} } from './${folder}.service';\n\n@Module({\n controllers: [${controllerName}],\n providers: [${serviceName}],\n})\nexport class ${moduleName} {}\n`; return { folder, files: { [`server/src/modules/${folder}/${folder}.controller.ts`]: controller, [`server/src/modules/${folder}/${folder}.service.ts`]: service, [`server/src/modules/${folder}/${folder}.module.ts`]: mod, [`server/src/modules/${folder}/dto/create-${folder}.dto.ts`]: createDtoLines.join('\n') + '\n', [`server/src/modules/${folder}/dto/update-${folder}.dto.ts`]: updateDtoLines.join('\n') + '\n', }, moduleName, importPath: `./modules/${folder}/${folder}.module`, }; } function renderFrontendResource(entityName, entity, resourceName, pk, enums, allEntities) { const folder = toKebab(entityName); const className = entityName; const enumAttrs = entity.attributes.filter( (a) => !['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type) ); const statusEnumAttr = enumAttrs.find((a) => a.name === 'status'); const identBase = toIdentifierFromKebab(folder); const filtersIdent = `${identBase}Filters`; const sortField = getBestSortField(entity, pk); const hasNumber = entity.attributes.some((a) => ['integer', 'decimal'].includes(a.type)); const hasDate = entity.attributes.some((a) => a.type === 'date'); const hasFK = entity.attributes.some((a) => a.foreign); const hasNonStatusEnum = enumAttrs.some((a) => a.name !== 'status'); const listImportSet = new Set([ 'List', 'Datagrid', 'TextField', 'TextInput', 'TopToolbar', 'FilterButton', 'CreateButton', 'ExportButton', ]); if (hasNumber) listImportSet.add('NumberField'); if (hasDate) listImportSet.add('DateField'); if (enumAttrs.length) listImportSet.add('SelectField'); if (hasFK) listImportSet.add('ReferenceField'); if (statusEnumAttr) listImportSet.add('SelectArrayInput'); if (hasNonStatusEnum) listImportSet.add('SelectInput'); if (hasFK) { listImportSet.add('ReferenceInput'); listImportSet.add('AutocompleteInput'); } const listImports = Array.from(listImportSet); const choiceConsts = []; for (const a of enumAttrs) { const enumName = a.type; const values = enums?.[enumName]?.values ?? []; const labels = enums?.[enumName]?.labels ?? {}; const constName = `${a.name}Choices`; if (a.name === 'status') { choiceConsts.push( `const statusChoices = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n` ); } else { choiceConsts.push( `const ${constName} = [\n${values.map((v) => ` { id: '${v}', name: '${labels[v] ?? v}' },`).join('\n')}\n];\n` ); } } const filterInputs = []; // Always include q if any string fields if (entity.attributes.some((a) => ['string', 'text'].includes(a.type))) { filterInputs.push(``); } for (const a of entity.attributes) { const label = getAttributeLabel(a, allEntities); if (a.name === pk) continue; if (a.foreign) { const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]); filterInputs.push( `\n ({ q: searchText })} />\n ` ); continue; } if (['string', 'text', 'uuid'].includes(a.type)) { filterInputs.push(``); continue; } if (['integer', 'decimal'].includes(a.type)) continue; if (a.type === 'date') continue; // enum if (a.name === 'status') { filterInputs.push(``); } else { filterInputs.push(``); } } const listFields = []; for (const a of entity.attributes) { const label = getAttributeLabel(a, allEntities); if (a.foreign) { const referenceEntity = allEntities[a.foreign.entity]; const referenceAttrs = getEntityAttrNames(referenceEntity); const fieldSource = referenceAttrs.has('inventoryNumber') ? 'inventoryNumber' : referenceAttrs.has('code') ? 'code' : referenceAttrs.has('number') ? 'number' : 'name'; listFields.push( `\n \n ` ); continue; } if (a.type === 'date') { listFields.push(``); } else if (['integer', 'decimal'].includes(a.type)) { listFields.push(``); } else if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) { listFields.push(``); } else { listFields.push(``); } } const list = `import {\n ${listImports.join(',\n ')}\n} from 'react-admin';\n\n${choiceConsts.join('\n')}\nconst ${filtersIdent} = [\n ${filterInputs.join(',\n ')}\n];\n\nconst ${className}ListActions = () => (\n \n \n \n \n \n);\n\nexport const ${className}List = () => (\n } filters={${filtersIdent}} sort={{ field: '${sortField}', order: 'ASC' }}>\n \n ${listFields.join('\n ')}\n \n \n);\n`; const formField = (a, mode) => { const label = getAttributeLabel(a, allEntities); if (a.isPrimary && mode === 'create' && a.type === 'uuid') return null; if (a.isPrimary && mode === 'edit') { return ``; } if (a.foreign) { const referenceDisplay = getReferenceDisplayExpr(allEntities[a.foreign.entity]); return `\n ({ q: searchText })} />\n `; } if (a.type === 'date') return ``; if (['integer', 'decimal'].includes(a.type)) return ``; if (!['string', 'text', 'uuid', 'integer', 'decimal', 'date'].includes(a.type)) { if (a.name === 'status' && statusEnumAttr) return ``; return ``; } return ``; }; const formImportSet = new Set(['SimpleForm', 'TextInput']); if (hasNumber) formImportSet.add('NumberInput'); if (hasDate) formImportSet.add('DateInput'); if (enumAttrs.length) formImportSet.add('SelectInput'); if (hasFK) { formImportSet.add('ReferenceInput'); formImportSet.add('AutocompleteInput'); } const createImports = ['Create', ...Array.from(formImportSet)].join(', '); const create = `import { ${createImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Create = () => (\n \n \n ${entity.attributes.map((a) => formField(a, 'create')).filter(Boolean).join('\n ')}\n \n \n);\n`; const editImports = ['Edit', ...Array.from(formImportSet)].join(', '); const edit = `import { ${editImports} } from 'react-admin';\n\n${choiceConsts.join('\n')}\nexport const ${className}Edit = () => (\n \n \n ${entity.attributes.map((a) => formField(a, 'edit')).filter(Boolean).join('\n ')}\n \n \n);\n`; const show = `import { Show, SimpleShowLayout, TextField } from 'react-admin';\n\nexport const ${className}Show = () => (\n \n \n ${entity.attributes.map((a) => ``).join('\n ')}\n \n \n);\n`; return { files: { [`client/src/resources/${folder}/${className}List.tsx`]: list, [`client/src/resources/${folder}/${className}Create.tsx`]: create, [`client/src/resources/${folder}/${className}Edit.tsx`]: edit, [`client/src/resources/${folder}/${className}Show.tsx`]: show, }, resourceName, className, folder, }; } function upsertInFile(filePath, apply, updater) { const abs = path.join(ROOT, filePath); const existing = fs.existsSync(abs) ? readFile(abs) : ''; const next = updater(existing); if (apply) writeFile(abs, next); return { changed: next !== existing, content: next }; } function ensureAppModule(apply, backendModules) { return upsertInFile('server/src/app.module.ts', apply, (src) => { let out = src; for (const m of backendModules) { if (!out.includes(`import { ${m.moduleName} }`)) { const importLine = `import { ${m.moduleName} } from '${m.importPath}';`; const importMatches = [...out.matchAll(/^import\s+.*;$/gm)]; if (importMatches.length) { const lastImport = importMatches[importMatches.length - 1]; const insertAt = lastImport.index + lastImport[0].length; out = `${out.slice(0, insertAt)}\n${importLine}${out.slice(insertAt)}`; } else { out = `${importLine}\n${out}`; } } } out = out.replace(/imports:\s*\[\s*([\s\S]*?)\s*\],/m, (match, inner) => { let block = inner; for (const m of backendModules) { if (!block.includes(m.moduleName)) block = block.replace(/\s*\],?\s*$/m, '') + `\n ${m.moduleName},`; } // normalize trailing comma/indent by reusing original replacement style return `imports: [${block}\n ],`; }); return out; }); } function ensureClientApp(apply, frontendResources) { return upsertInFile('client/src/App.tsx', apply, (src) => { let out = src; for (const r of frontendResources) { const imports = [ `import { ${r.className}List } from './resources/${r.folder}/${r.className}List';`, `import { ${r.className}Create } from './resources/${r.folder}/${r.className}Create';`, `import { ${r.className}Edit } from './resources/${r.folder}/${r.className}Edit';`, `import { ${r.className}Show } from './resources/${r.folder}/${r.className}Show';`, ]; for (const imp of imports) { if (!out.includes(imp)) { const importMatches = [...out.matchAll(/^import\s+.*;$/gm)]; if (importMatches.length) { const lastImport = importMatches[importMatches.length - 1]; const insertAt = lastImport.index + lastImport[0].length; out = `${out.slice(0, insertAt)}\n${imports.join('\n')}${out.slice(insertAt)}`; } else { out = `${imports.join('\n')}\n${out}`; } break; } } if (!out.includes(`name="${r.resourceName}"`)) { out = out.replace( /<\/Admin>/m, ` \n ` ); } } return out; }); } function main() { const args = process.argv.slice(2); const apply = args.includes('--apply'); const dslArgIdx = args.indexOf('--dsl'); const dslPath = dslArgIdx >= 0 ? args[dslArgIdx + 1] : 'examples/TOiR.domain.dsl'; const absDsl = path.resolve(ROOT, dslPath); const dslText = readFile(absDsl); const parsed = parseDomainDSL(dslText); // Prisma schema const prismaPath = path.join(ROOT, 'server/prisma/schema.prisma'); ensurePrismaSchema(parsed, prismaPath, apply); // Backend modules + frontend resources const backendModules = []; const frontendResources = []; for (const [entityName, ent] of Object.entries(parsed.entities)) { const pk = ent.primaryKey; const resource = pluralize(toKebab(entityName)); const be = renderBackendModule(entityName, ent, resource, pk); const fe = renderFrontendResource(entityName, ent, resource, pk, parsed.enums, parsed.entities); backendModules.push(be); frontendResources.push(fe); if (apply) { for (const [rel, content] of Object.entries(be.files)) writeFile(path.join(ROOT, rel), content); for (const [rel, content] of Object.entries(fe.files)) writeFile(path.join(ROOT, rel), content); } } ensureAppModule(apply, backendModules); ensureClientApp(apply, frontendResources); process.stdout.write( `${apply ? 'Generated' : 'Planned'} ${Object.keys(parsed.entities).length} entities from ${dslPath}\n` ); } main();