/** * Frontend Module Generator * * Main orchestrator for generating frontend modules from JSON schema. */ import * as fs from "fs"; import * as path from "path"; import { generateContainerTemplate, generateContentTemplate, generateContextTemplate, generateDeleterTemplate, generateDetailPageTemplate, generateEditorTemplate, generateFieldsTemplate, generateAliasesTemplate, generateInterfaceTemplate, generateListContainerTemplate, generateListPageTemplate, generateListTemplate, generateModelTemplate, generateModuleTemplate, generateMultiSelectorTemplate, generateSelectorTemplate, generateServiceTemplate, generateTableHookTemplate, } from "./templates"; import { filterInheritedFields, mapFields } from "./transformers/field-mapper"; import { generateI18nKeys } from "./transformers/i18n-generator"; import { buildFilePaths, buildImportStatements } from "./transformers/import-resolver"; import { toCamelCase, transformNames } from "./transformers/name-transformer"; import { detectExtendsContent } from "./transformers/parent-detector"; import { generateServiceMethods, resolveRelationships } from "./transformers/relationship-resolver"; import { FrontendTemplateData, GeneratedFile, GenerateWebModuleOptions, JsonModuleDefinition } from "./types"; import { formatFiles, printResults, updateBootstrapper, updateI18n, writeFiles } from "./utils"; import { parseAndValidate, validationPassed } from "./validators/json-schema-validator"; /** * Generate all frontend module files from JSON schema * * @param options - Generator options * @returns True if generation succeeded */ export async function generateWebModule(options: GenerateWebModuleOptions): Promise { const { jsonPath, dryRun = false, force = false, noRegister = false } = options; console.info(`\nšŸ”§ Frontend Module Generator`); console.info(` JSON Schema: ${jsonPath}`); console.info(` Dry Run: ${dryRun}`); console.info(` Force: ${force}`); console.info(` No Register: ${noRegister}`); // Step 1: Validate JSON schema console.info("\nšŸ“‹ Validating JSON schema..."); const validation = parseAndValidate(jsonPath); if (!validationPassed(validation)) { console.error("\nāŒ Validation failed:"); validation.errors.forEach((e) => console.error(` - ${e}`)); return false; } if (validation.warnings.length > 0) { console.info("\nāš ļø Validation warnings (non-blocking):"); validation.warnings.forEach((w) => console.info(` - ${w}`)); } const schemas = validation.data!; const total = schemas.length; console.info(` āœ… ${total} schema(s) valid`); // Determine web base path once const webBasePath = findWebBasePath(jsonPath); if (!webBasePath) { console.error("\nāŒ Could not determine web app base path"); return false; } console.info(` Web base path: ${webBasePath}`); // Build target hasName map from all structure files for cross-module awareness const structureDir = path.dirname(jsonPath); const targetHasNameMap = buildTargetHasNameMap(structureDir); for (let i = 0; i < total; i++) { const schema = schemas[i]; console.info(`\nšŸ“¦ Processing module ${i + 1}/${total}: ${schema.moduleName}`); console.info(`${"─".repeat(50)}`); // Step 2: Build template data console.info("šŸ”Ø Building template data..."); const templateData = buildTemplateData(schema, targetHasNameMap); console.info(` Module: ${templateData.names.pascalCase}`); console.info(` Target: ${templateData.targetDir}`); console.info(` Extends Content: ${templateData.extendsContent}`); console.info(` Fields: ${templateData.fields.length}`); console.info(` Relationships: ${templateData.relationships.length}`); // Step 3: Generate all files console.info("\nšŸ“ Generating files..."); const files = generateAllFiles(templateData, schema); console.info(` Generated ${files.length} file templates`); // Step 5: Write files console.info("\nšŸ’¾ Writing files..."); const results = writeFiles(files, { dryRun, force }); printResults(results); // Step 5b: Format files with Prettier if (!dryRun) { const writtenPaths = results.filter((r) => r.status === "created" || r.status === "updated").map((r) => r.path); formatFiles(writtenPaths); } // Step 6: Update Bootstrapper (unless --no-register) if (!noRegister) { console.info("\nšŸ”§ Updating Bootstrapper.ts..."); const bootstrapResult = updateBootstrapper(templateData, webBasePath, dryRun); console.info(` ${bootstrapResult.success ? "āœ…" : "āŒ"} ${bootstrapResult.message}`); } // Step 7: Update i18n for all languages (unless --no-register) if (!noRegister) { const languages = schema.languages || ["en"]; console.info(`\n🌐 Updating i18n for ${languages.length} language(s)...`); for (const language of languages) { const i18nResult = updateI18n(templateData, webBasePath, language, dryRun); console.info(` ${i18nResult.success ? "āœ…" : "āŒ"} ${i18nResult.message}`); } } console.info(`\nāœ… ${schema.moduleName} generation complete!`); } // Final summary console.info(`\n${"═".repeat(50)}`); console.info(`āœ… All done! ${total} module(s) generated successfully.`); if (dryRun) { console.info("\nšŸ“ This was a dry run. No files were actually written."); console.info(" Remove --dry-run to generate files."); } return true; } /** * Build a map of moduleName → hasNameField by scanning all structure files. * Foundation entities (User, Company, etc.) always have name, so default to true for unknowns. */ function buildTargetHasNameMap(structureDir: string): Map { const map = new Map(); if (!fs.existsSync(structureDir)) return map; const files = fs.readdirSync(structureDir).filter((f) => f.endsWith(".json")); for (const file of files) { try { const content = fs.readFileSync(path.join(structureDir, file), "utf-8"); const schemas: JsonModuleDefinition[] = JSON.parse(content); for (const schema of schemas) { const hasName = schema.extendsContent === true || schema.fields.some((f) => f.name === "name"); map.set(schema.moduleName, hasName); } } catch { // Skip invalid files } } return map; } /** * Build template data from JSON schema */ function buildTemplateData(schema: JsonModuleDefinition, targetHasNameMap?: Map): FrontendTemplateData { const names = transformNames(schema.moduleName, schema.endpointName); // Keep original targetDir for file path generation (buildFilePaths handles stripping) const targetDir = schema.targetDir; // Strip the first segment (features or foundations) for import paths // since @/features/ alias already includes it (e.g., "features/culling" → "culling") const importTargetDir = schema.targetDir.split("/").slice(1).join("/") || schema.targetDir; const extendsContent = detectExtendsContent(schema.fields, schema.extendsContent); // Map fields const allFields = mapFields(schema.fields, names.camelCase); allFields.forEach((f) => { const src = schema.fields.find((sf) => sf.name === f.name); f.readOnly = src?.readOnly === true; }); // Get inherited fields to filter out for forms (Content fields are handled specially) const inheritedFields = extendsContent ? ["name", "tldr", "abstract", "content"] : []; const fields = filterInheritedFields(allFields, inheritedFields); // Resolve relationships const relationships = resolveRelationships(schema.relationships, targetHasNameMap); // Determine which aliases conflict (multiple aliases targeting the same entity) const entityAliasNames = new Map(); for (const rel of relationships) { if (rel.alias) { const key = rel.name; if (!entityAliasNames.has(key)) entityAliasNames.set(key, []); entityAliasNames.get(key)!.push(rel.alias); } } const conflictingAliases = new Set(); for (const aliases of entityAliasNames.values()) { if (aliases.length > 1) { for (const alias of aliases) conflictingAliases.add(alias); } } const relationshipServiceMethods = generateServiceMethods(relationships, conflictingAliases); // Build imports const imports = buildImportStatements(relationships, extendsContent); // Generate i18n keys const i18nKeys = generateI18nKeys(names, fields, relationships); // Build table field names const tableFieldNames = buildTableFieldNames(schema, extendsContent); return { names, moduleId: schema.moduleId, endpoint: schema.endpointName, targetDir, importTargetDir, extendsContent, fields: allFields, // Keep all fields for interface generation relationships, i18nKeys, imports, tableFieldNames, relationshipServiceMethods, featureId: schema.featureId, displayProp: schema.displayProp ?? (allFields.some((f) => f.name === "name") ? "name" : "id"), containerTabs: { // Opt-in: the Activity tab imports an app-level ActivityFeed feature that // many apps don't have. Only emit it when the schema explicitly asks. activity: schema.containerTabs?.activity === true, relations: schema.containerTabs?.relations ?? [], }, relatedInclusions: schema.inclusions?.related ?? [], }; } /** * Build field names for table display */ function buildTableFieldNames(schema: JsonModuleDefinition, extendsContent: boolean): string[] { const fieldNames: string[] = [`${toCamelCase(schema.moduleName)}Id`]; // Add name for all modules fieldNames.push("name"); // Add authors for content modules if (extendsContent) { fieldNames.push("authors"); } // Add custom fields (excluding certain ones, and rich-text BlockNote fields // which hold stringified JSON and must not become table columns) schema.fields.forEach((f) => { if (f.type === "blocknote") return; if (!["id", "name", "tldr", "abstract", "content", "createdAt", "updatedAt"].includes(f.name)) { fieldNames.push(f.name); } }); // Add standard date fields fieldNames.push("createdAt", "updatedAt"); return fieldNames; } /** * Generate all module files */ function generateAllFiles(data: FrontendTemplateData, schema: JsonModuleDefinition): GeneratedFile[] { const files: GeneratedFile[] = []; const paths = buildFilePaths(data, findWebBasePath("")); // Data layer (4 files) files.push({ path: paths.interface, content: generateInterfaceTemplate(data), type: "data", }); files.push({ path: paths.model, content: generateModelTemplate(data), type: "data", }); files.push({ path: paths.service, content: generateServiceTemplate(data), type: "data", }); files.push({ path: paths.fields, content: generateFieldsTemplate(data), type: "data", }); // Aliases file (only when module has aliased relationships) const aliasesContent = generateAliasesTemplate(data); if (aliasesContent) { files.push({ path: paths.aliases, content: aliasesContent, type: "data", }); } // Form components (4 files) files.push({ path: paths.editor, content: generateEditorTemplate(data), type: "component", }); files.push({ path: paths.deleter, content: generateDeleterTemplate(data), type: "component", }); files.push({ path: paths.selector, content: generateSelectorTemplate(data), type: "component", }); files.push({ path: paths.multiSelector, content: generateMultiSelectorTemplate(data), type: "component", }); // Display components (4-5 files) files.push({ path: paths.list, content: generateListTemplate(data), type: "component", }); // Content component — always emitted (Details tab body) files.push({ path: paths.content, content: generateContentTemplate(data), type: "component", }); files.push({ path: paths.container, content: generateContainerTemplate(data), type: "component", }); files.push({ path: paths.listContainer, content: generateListContainerTemplate(data), type: "component", }); // Context & hooks (2 files) files.push({ path: paths.context, content: generateContextTemplate(data), type: "context", }); files.push({ path: paths.tableHook, content: generateTableHookTemplate(data), type: "hook", }); // Module (1 file) files.push({ path: paths.module, content: generateModuleTemplate(data), type: "module", }); // Pages (2 files) files.push({ path: paths.listPage, content: generateListPageTemplate(data), type: "page", }); files.push({ path: paths.detailPage, content: generateDetailPageTemplate(data), type: "page", }); return files; } /** * Find the web app base path from the JSON path * Assumes JSON is in /structure folder and web app is in /apps/web */ function findWebBasePath(jsonPath: string): string { // Try to find apps/web relative to current working directory const cwd = process.cwd(); // Check if we're in the monorepo root if (fs.existsSync(path.join(cwd, "apps/web"))) { return cwd; } // Check if we're in packages/nextjs-jsonapi if (fs.existsSync(path.join(cwd, "../../apps/web"))) { return path.join(cwd, "../.."); } // Try to derive from jsonPath if (jsonPath) { const resolved = path.resolve(jsonPath); // Look for monorepo root by finding apps/web let current = path.dirname(resolved); for (let i = 0; i < 10; i++) { if (fs.existsSync(path.join(current, "apps/web"))) { return current; } const parent = path.dirname(current); if (parent === current) break; current = parent; } } // Default to current directory return cwd; } export default generateWebModule;