Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
@@ -0,0 +1,235 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { modelToSchema } from '#services/swagger_model'
import { ModuleContext } from '#services/module_generator/module_context'
import {
capitalize,
pascalCase,
singularize,
tableToModelSchemaName,
tableToModelName,
} from '#services/module_generator/naming'
import { renderTemplate } from '#services/module_generator/writers/template_renderer'
import { ModuleAsset } from '#services/module_generator/types'
export interface WriteBackendModuleOptions {
name: string
table: string
generate: ModuleAsset[]
appBase: string
context: ModuleContext
}
/** Renders the selected backend assets from app/templates/backend into modules/<Module>. */
export async function writeBackendModule(options: WriteBackendModuleOptions) {
const { name, table, generate, appBase, context } = options
const { enhancedColumns, createFields, updateFields, insertJson, relationships } = context
const backendTplDir = join(appBase, 'app/templates/backend')
const apiTplDir = join(appBase, 'app/templates/backend/api')
const moduleName = capitalize(name)
const modulePath = join(appBase, 'modules', moduleName)
const modelClass = capitalize(table)
const snake = singularize(table)
const paths = {
controller: join(modulePath, 'controller', `${snake}_controller.ts`),
model: join(modulePath, 'model', `${tableToModelName(table)}.ts`),
// model: join(modulePath, 'model', `${snake}.ts`),
validatorCreate: join(modulePath, 'validators', `${snake}_create_validator.ts`),
validatorUpdate: join(modulePath, 'validators', `${snake}_update_validator.ts`),
routes: join(modulePath, 'routes.ts'),
policy: join(modulePath, 'policy', `${snake}_policy.ts`),
policiesMain: join(appBase, 'app', 'policies', 'main.ts'),
permissionsConfig: join(modulePath, 'config', 'permissions.ts'),
// API subtree
apiController: join(modulePath, 'api', 'controllers', `${snake}_api_controller.ts`),
apiValidatorCreate: join(modulePath, 'api', 'validators', `${snake}_create_api_validator.ts`),
apiValidatorUpdate: join(modulePath, 'api', 'validators', `${snake}_update_api_validator.ts`),
apiRoutes: join(modulePath, 'api', 'routes.ts'),
// tests
testController: join(appBase, 'tests', 'modules', moduleName, `${snake}.spec.ts`),
testApi: join(appBase, 'tests', 'modules', moduleName, `${snake}.api.spec.ts`),
}
Object.values(paths).forEach((filePath) => {
mkdirSync(dirname(filePath), { recursive: true })
})
if (generate.includes(ModuleAsset.MODEL)) {
const content = renderTemplate(join(backendTplDir, 'model.hbs'), {
name,
pascalCaseName: pascalCase(name),
capName: capitalize(name),
className: modelClass,
imports: relationships.imports.join(', '),
columns: enhancedColumns,
relationships: relationships.code,
hasPassword: context.hasPassword,
tableName: table,
modelSchemaName: tableToModelSchemaName(table),
})
writeFileSync(paths.model, content)
}
if (generate.includes(ModuleAsset.CONTROLLER)) {
const content = renderTemplate(join(backendTplDir, 'controller.hbs'), {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeName: snake,
name,
})
writeFileSync(paths.controller, content)
const ctrlSpec = renderTemplate(join(backendTplDir, 'tests', 'controller.spec.hbs'), {
pascalCaseName: pascalCase(name),
snakeCaseName: snake,
tableName: table,
createFields,
updateFields,
insertJson,
})
writeFileSync(paths.testController, ctrlSpec, 'utf8')
}
if (generate.includes(ModuleAsset.ROUTES)) {
const content = renderTemplate(join(backendTplDir, 'routes.hbs'), {
name,
capName: capitalize(name),
className: moduleName,
routeName: name,
snakeCaseName: snake,
})
writeFileSync(paths.routes, content)
}
if (generate.includes(ModuleAsset.VALIDATOR)) {
const createContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `create${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isSpecial),
})
writeFileSync(paths.validatorCreate, createContent)
const updateContent = renderTemplate(join(backendTplDir, 'validator.hbs'), {
className: moduleName,
functionName: `update${moduleName}Validator`,
fields: enhancedColumns.filter((col) => !col.isPrimary && !col.isDateTime),
})
writeFileSync(paths.validatorUpdate, updateContent)
}
if (generate.includes(ModuleAsset.POLICY)) {
writePolicy(paths, backendTplDir, name, moduleName, modelClass)
}
if (generate.includes(ModuleAsset.API)) {
const { default: genModel } = await import(paths.model)
const apiCtx = {
pascalCaseName: pascalCase(name),
modelName: modelClass,
capName: capitalize(name),
moduleName,
tableName: table,
snakeCaseName: snake,
name,
snake,
swaggerModel: await modelToSchema(genModel),
}
writeFileSync(
paths.apiController,
renderTemplate(join(apiTplDir, 'controller.hbs'), apiCtx),
'utf8'
)
const apiSpec = renderTemplate(join(backendTplDir, 'tests', 'api.spec.hbs'), {
snakeCaseName: snake,
createFields,
})
writeFileSync(paths.testApi, apiSpec, 'utf8')
const apiValidatorTpl = join(apiTplDir, 'validator.hbs')
const apiCreateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `create${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isSpecial),
})
writeFileSync(paths.apiValidatorCreate, apiCreateValidatorContent, 'utf8')
const apiUpdateValidatorContent = renderTemplate(apiValidatorTpl, {
...apiCtx,
className: moduleName,
functionName: `update${moduleName}ApiValidator`,
fields: enhancedColumns.filter((c) => !c.isPrimary && !c.isDateTime),
})
writeFileSync(paths.apiValidatorUpdate, apiUpdateValidatorContent, 'utf8')
writeFileSync(paths.apiRoutes, renderTemplate(join(apiTplDir, 'routes.hbs'), apiCtx), 'utf8')
}
}
/** Policy file + registration in app/policies/main.ts + module permissions config. */
function writePolicy(
paths: { policy: string; policiesMain: string; permissionsConfig: string },
backendTplDir: string,
name: string,
moduleName: string,
modelClass: string
) {
const base = name.toLowerCase()
const slugBase = base.endsWith('s') ? base : `${base}s`
const policyContent = renderTemplate(join(backendTplDir, 'policy.hbs'), {
name,
slugBase,
moduleName,
modelName: modelClass,
pascalCaseName: pascalCase(name),
})
writeFileSync(paths.policy, policyContent)
if (!existsSync(paths.policiesMain)) {
mkdirSync(dirname(paths.policiesMain), { recursive: true })
writeFileSync(paths.policiesMain, `export const policies = {}\n`)
}
const capName = capitalize(name)
const key = `${modelClass}Policy`
const importLine = ` ${key}: () => import('#modules/${capName}/policy/${name}_policy'),`
const current = readFileSync(paths.policiesMain, 'utf8')
if (current.includes('export const policies =')) {
if (!current.includes(importLine)) {
const updated = current.replace(
/export const policies\s*=\s*\{\s*/m,
(m) => m + '\n' + importLine + '\n'
)
writeFileSync(paths.policiesMain, updated)
}
} else {
writeFileSync(paths.policiesMain, `export const policies = {\n${importLine}\n}\n`)
}
const labelBase = capitalize(name.endsWith('s') ? name.slice(0, -1) : name)
const perms = [
{ action: 'view', slug: `${name}s.view`, name: `${labelBase} view` },
{ action: 'create', slug: `${name}s.create`, name: `${labelBase} create` },
{ action: 'update', slug: `${name}s.update`, name: `${labelBase} update` },
{ action: 'delete', slug: `${name}s.delete`, name: `${labelBase} delete` },
]
const fileContents =
'export default [\n' +
perms.map((p) => ` {\n slug: '${p.slug}',\n name: '${p.name}',\n },`).join('\n') +
'\n]\n'
if (!existsSync(paths.permissionsConfig)) {
mkdirSync(dirname(paths.permissionsConfig), { recursive: true })
writeFileSync(paths.permissionsConfig, fileContents, 'utf8')
}
}
@@ -0,0 +1,59 @@
import { copyFileSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import handlebars from 'handlebars'
const HBS_EXTENSION = '.hbs'
export interface WriteFrontendModuleOptions {
/** Root of the module template tree (app/templates/frontend/module). */
templateDir: string
/** Module output directory inside the admin workspace. */
outDir: string
/** Context for rendering the `*.hbs` files in the tree. */
context: Record<string, unknown>
/**
* Generated sources keyed by module-relative path. They are written after the
* template tree, replacing the scaffold defaults. A null value means the asset
* was not selected — the scaffold default from the tree is kept as-is.
*/
overrides: Record<string, string | null>
}
/**
* Materializes a frontend module from the template tree: `*.hbs` files are
* rendered with `context` and written without the extension, every other file
* is copied verbatim, and directory nesting is preserved. Builder-generated
* sources in `overrides` are then written on top.
*/
export function writeFrontendModule(options: WriteFrontendModuleOptions) {
renderTree(options.templateDir, options.outDir, options.context)
for (const [relativePath, content] of Object.entries(options.overrides)) {
if (content === null) continue
const target = join(options.outDir, relativePath)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content, 'utf8')
}
}
function renderTree(dir: string, outDir: string, context: Record<string, unknown>) {
mkdirSync(outDir, { recursive: true })
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const sourcePath = join(dir, entry.name)
if (entry.isDirectory()) {
renderTree(sourcePath, join(outDir, entry.name), context)
continue
}
if (entry.name.endsWith(HBS_EXTENSION)) {
// noEscape: the templates emit TypeScript, HTML entities would corrupt it.
const template = handlebars.compile(readFileSync(sourcePath, 'utf8'), { noEscape: true })
writeFileSync(join(outDir, entry.name.slice(0, -HBS_EXTENSION.length)), template(context))
} else {
copyFileSync(sourcePath, join(outDir, entry.name))
}
}
}
@@ -0,0 +1,13 @@
import { readFileSync } from 'node:fs'
import handlebars from 'handlebars'
handlebars.registerHelper('eq', (a, b) => a === b)
handlebars.registerHelper('not', (v) => !v)
handlebars.registerHelper('or', (a, b) => a || b)
handlebars.registerHelper('and', (a, b) => a && b)
export function renderTemplate(templatePath: string, context: Record<string, unknown>): string {
const raw = readFileSync(templatePath, 'utf8')
const template = handlebars.compile(raw)
return template(context)
}