Add initial project files
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user