Skip to content

Commit 3158d04

Browse files
committed
feat: add custom component generation feature
1 parent b579484 commit 3158d04

File tree

11 files changed

+824
-80
lines changed

11 files changed

+824
-80
lines changed

adminforth/commands/cli.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import bundle from "./bundle.js";
77
import createApp from "./createApp/main.js";
88
import generateModels from "./generateModels.js";
99
import createPlugin from "./createPlugin/main.js";
10+
import createComponent from "./createCustomComponent/main.js";
11+
1012
switch (command) {
1113
case "create-app":
1214
createApp(args);
@@ -20,8 +22,11 @@ switch (command) {
2022
case "bundle":
2123
bundle();
2224
break;
25+
case "custom-component":
26+
createComponent(args);
27+
break;
2328
default:
2429
console.log(
25-
"Unknown command. Available commands: create-app, create-plugin, generate-models, bundle"
30+
"Unknown command. Available commands: create-app, create-plugin, generate-models, bundle, custom-component"
2631
);
2732
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import chalk from 'chalk';
4+
import jiti from 'jiti';
5+
6+
7+
export async function loadAdminForthConfig() {
8+
const configFileName = 'index.ts';
9+
const configPath = path.resolve(process.cwd(), configFileName);
10+
11+
try {
12+
await fs.access(configPath);
13+
} catch (error) {
14+
console.error(chalk.red(`\nError: Configuration file not found at ${configPath}`));
15+
console.error(chalk.yellow(`Please ensure you are running this command from your project's root directory and the '${configFileName}' file exists.`));
16+
process.exit(1);
17+
}
18+
19+
try {
20+
const _require = jiti(import.meta.url, {
21+
interopDefault: true,
22+
cache: true,
23+
esmResolve: true,
24+
});
25+
26+
const configModule = _require(configPath);
27+
28+
const adminInstance = configModule.admin || configModule.default?.admin;
29+
30+
31+
if (!adminInstance) {
32+
throw new Error(`Could not find 'admin' export in ${configFileName}. Please ensure your config file exports the AdminForth instance like: 'export const admin = new AdminForth({...});'`);
33+
}
34+
35+
const config = adminInstance.config;
36+
37+
if (!config || typeof config !== 'object') {
38+
throw new Error(`Invalid configuration found in admin instance from ${configFileName}. Expected admin.config to be an object.`);
39+
}
40+
41+
if (!config.resources || !Array.isArray(config.resources)) {
42+
console.warn(chalk.yellow(`Warning: The loaded configuration seems incomplete. Missing 'resources' array.`));
43+
}
44+
if (!config.customization?.customComponentsDir) {
45+
console.warn(chalk.yellow(`Warning: 'customization.customComponentsDir' is not defined in the config. Defaulting might occur elsewhere, but defining it is recommended.`));
46+
}
47+
48+
49+
console.log(chalk.dim(`Loaded configuration from ${configPath}`));
50+
return config;
51+
52+
} catch (error) {
53+
console.error(chalk.red(`\nError loading or parsing configuration file: ${configPath}`));
54+
console.error(error);
55+
process.exit(1);
56+
}
57+
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import chalk from 'chalk';
4+
import recast from 'recast'; // Import recast
5+
import * as typescriptParser from 'recast/parsers/typescript.js'; // Import the parser using ESM and include the .js extension
6+
7+
const b = recast.types.builders; // Like t.* in babel/types
8+
const n = recast.types.namedTypes; // Like t.is* in babel/types
9+
10+
11+
async function findResourceFilePath(resourceId) {
12+
const projectRoot = process.cwd();
13+
const resourcesDir = path.resolve(projectRoot, 'resources');
14+
console.log(chalk.dim(`Scanning for resource files in: ${resourcesDir}`));
15+
16+
let tsFiles = [];
17+
try {
18+
const entries = await fs.readdir(resourcesDir, { withFileTypes: true });
19+
tsFiles = entries
20+
.filter(dirent => dirent.isFile() && dirent.name.endsWith('.ts') && !dirent.name.endsWith('.d.ts'))
21+
.map(dirent => dirent.name);
22+
} catch (error) {
23+
if (error.code === 'ENOENT') {
24+
throw new Error(`Resources directory not found at ${resourcesDir}. Please ensure it exists.`);
25+
}
26+
throw new Error(`Failed to read resources directory ${resourcesDir}: ${error.message}`);
27+
}
28+
29+
console.log(chalk.dim(`Found .ts files to scan: ${tsFiles.join(', ') || 'None'}`));
30+
31+
for (const file of tsFiles) {
32+
const filePath = path.resolve(resourcesDir, file);
33+
console.log(chalk.dim(`Attempting to process file: ${file}`));
34+
try {
35+
const content = await fs.readFile(filePath, 'utf-8');
36+
const ast = recast.parse(content, {
37+
parser: typescriptParser
38+
});
39+
40+
let foundResourceId = null;
41+
42+
recast.visit(ast, {
43+
visitExportDefaultDeclaration(path) {
44+
if (foundResourceId !== null) return false; // Stop visiting deeper if already found
45+
46+
const declaration = path.node.declaration;
47+
let objectExpressionNode = null;
48+
49+
if (n.TSAsExpression.check(declaration) && n.ObjectExpression.check(declaration.expression)) {
50+
objectExpressionNode = declaration.expression;
51+
}
52+
else if (n.ObjectExpression.check(declaration)) {
53+
objectExpressionNode = declaration;
54+
}
55+
56+
if (objectExpressionNode) {
57+
const resourceIdProp = objectExpressionNode.properties.find(prop =>
58+
n.ObjectProperty.check(prop) &&
59+
n.Identifier.check(prop.key) &&
60+
prop.key.name === 'resourceId' &&
61+
n.StringLiteral.check(prop.value)
62+
);
63+
if (resourceIdProp) {
64+
foundResourceId = resourceIdProp.value.value; // Get the string value
65+
console.log(chalk.dim(` Extracted resourceId '${foundResourceId}' from ${file}`));
66+
this.abort(); // Stop traversal for this file once found
67+
}
68+
}
69+
return false;
70+
}
71+
});
72+
73+
console.log(chalk.dim(` Finished processing ${file}. Found resourceId: ${foundResourceId || 'null'}`));
74+
75+
if (foundResourceId === resourceId) {
76+
console.log(chalk.dim(` Match found! Returning path: ${filePath}`));
77+
return filePath;
78+
}
79+
} catch (parseError) {
80+
if (parseError.message.includes('require is not defined')) {
81+
console.error(chalk.red(`❌ Internal Error: Failed to load Recast parser in ESM context for ${file}.`));
82+
} else {
83+
console.warn(chalk.yellow(`⚠️ Warning: Could not process file ${file}. Skipping. Error: ${parseError.message}`));
84+
}
85+
}
86+
}
87+
88+
throw new Error(`Could not find a resource file in '${resourcesDir}' with resourceId: '${resourceId}'`);
89+
}
90+
91+
92+
export async function updateResourceConfig(resourceId, columnName, fieldType, componentPathForConfig) {
93+
const filePath = await findResourceFilePath(resourceId);
94+
console.log(chalk.dim(`Attempting to update resource config: ${filePath}`));
95+
96+
let content;
97+
try {
98+
content = await fs.readFile(filePath, 'utf-8');
99+
} catch (error) {
100+
console.error(chalk.red(`❌ Error reading resource file: ${filePath}`));
101+
console.error(error);
102+
throw new Error(`Could not read resource file ${filePath}.`);
103+
}
104+
105+
try {
106+
const ast = recast.parse(content, {
107+
parser: typescriptParser
108+
});
109+
110+
let updateApplied = false;
111+
112+
recast.visit(ast, {
113+
visitExportDefaultDeclaration(path) {
114+
const declaration = path.node.declaration;
115+
let objectExpressionNode = null;
116+
117+
if (n.TSAsExpression.check(declaration) && n.ObjectExpression.check(declaration.expression)) {
118+
objectExpressionNode = declaration.expression;
119+
} else if (n.ObjectExpression.check(declaration)) {
120+
objectExpressionNode = declaration;
121+
}
122+
123+
if (!objectExpressionNode) {
124+
console.warn(chalk.yellow(`Warning: Default export in ${filePath} is not a recognized ObjectExpression or TSAsExpression containing one. Skipping update.`));
125+
return false;
126+
}
127+
128+
const properties = objectExpressionNode.properties;
129+
const columnsProperty = properties.find(prop =>
130+
n.ObjectProperty.check(prop) && n.Identifier.check(prop.key) && prop.key.name === 'columns'
131+
);
132+
133+
if (!columnsProperty || !n.ArrayExpression.check(columnsProperty.value)) {
134+
console.warn(chalk.yellow(`Warning: Could not find 'columns' array in the default export of ${filePath}. Skipping update.`));
135+
return false;
136+
}
137+
138+
const columnsArray = columnsProperty.value.elements;
139+
const targetColumn = columnsArray.find(col => {
140+
if (n.ObjectExpression.check(col)) {
141+
const nameProp = col.properties.find(p =>
142+
n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'name' &&
143+
n.StringLiteral.check(p.value) && p.value.value === columnName
144+
);
145+
return !!nameProp;
146+
}
147+
return false;
148+
});
149+
150+
if (!targetColumn || !n.ObjectExpression.check(targetColumn)) {
151+
return false;
152+
}
153+
154+
let componentsProperty = targetColumn.properties.find(p =>
155+
n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'components'
156+
);
157+
158+
if (!componentsProperty) {
159+
const newComponentsObject = b.objectExpression([]);
160+
componentsProperty = b.objectProperty(b.identifier('components'), newComponentsObject);
161+
162+
const nameIndex = targetColumn.properties.findIndex(p => n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === 'name');
163+
targetColumn.properties.splice(nameIndex !== -1 ? nameIndex + 1 : targetColumn.properties.length, 0, componentsProperty);
164+
console.log(chalk.dim(`Added 'components' object to column '${columnName}'.`));
165+
166+
} else if (!n.ObjectExpression.check(componentsProperty.value)) {
167+
console.warn(chalk.yellow(`Warning: 'components' property in column '${columnName}' is not an object. Skipping update.`));
168+
return false;
169+
}
170+
171+
const componentsObject = componentsProperty.value;
172+
let fieldTypeProperty = componentsObject.properties.find(p =>
173+
n.ObjectProperty.check(p) && n.Identifier.check(p.key) && p.key.name === fieldType
174+
);
175+
176+
const newComponentValue = b.stringLiteral(componentPathForConfig);
177+
178+
if (fieldTypeProperty) {
179+
fieldTypeProperty.value = newComponentValue;
180+
console.log(chalk.dim(`Updated '${fieldType}' component path in column '${columnName}'.`));
181+
} else {
182+
fieldTypeProperty = b.objectProperty(b.identifier(fieldType), newComponentValue);
183+
componentsObject.properties.push(fieldTypeProperty);
184+
console.log(chalk.dim(`Added '${fieldType}' component path to column '${columnName}'.`));
185+
}
186+
187+
updateApplied = true;
188+
this.abort();
189+
return false;
190+
}
191+
});
192+
193+
if (!updateApplied) {
194+
throw new Error(`Could not find column '${columnName}' or apply update within the default export's 'columns' array in ${filePath}.`);
195+
}
196+
197+
const outputCode = recast.print(ast).code;
198+
199+
await fs.writeFile(filePath, outputCode, 'utf-8');
200+
console.log(chalk.dim(`Successfully updated resource configuration file (preserving formatting): ${filePath}`));
201+
202+
} catch (error) {
203+
console.error(chalk.red(`❌ Error processing resource file: ${filePath}`));
204+
console.error(error);
205+
throw new Error(`Failed to update resource file ${path.basename(filePath)}: ${error.message}`);
206+
}
207+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import fs from 'fs/promises';
2+
import path from 'path';
3+
import chalk from 'chalk';
4+
import Handlebars from 'handlebars';
5+
import { fileURLToPath } from 'url';
6+
7+
async function renderHBSTemplate(templatePath, data) {
8+
try {
9+
const templateContent = await fs.readFile(templatePath, 'utf-8');
10+
const compiled = Handlebars.compile(templateContent);
11+
return compiled(data);
12+
} catch (error) {
13+
console.error(chalk.red(`❌ Error reading or compiling template: ${templatePath}`));
14+
throw error;
15+
}
16+
}
17+
18+
async function generateVueContent(fieldType, { resource, column }) {
19+
const componentName = `${resource.label}${column.label}${fieldType.charAt(0).toUpperCase() + fieldType.slice(1)}`;
20+
const columnName = column.name;
21+
const resourceId = resource.resourceId;
22+
23+
const __filename = fileURLToPath(import.meta.url);
24+
const __dirname = path.dirname(__filename);
25+
const templatePath = path.join(__dirname, 'templates', 'customFields', `${fieldType}.vue.hbs`);
26+
27+
console.log(chalk.dim(`Using template: ${templatePath}`));
28+
29+
const context = {
30+
componentName,
31+
columnName,
32+
resourceId,
33+
resource,
34+
column
35+
};
36+
37+
try {
38+
const fileContent = await renderHBSTemplate(templatePath, context);
39+
return fileContent;
40+
} catch (error) {
41+
console.error(chalk.red(`❌ Failed to generate content for ${componentName}.vue`));
42+
throw error;
43+
}
44+
}
45+
46+
export async function generateComponentFile(componentFileName, fieldType, context, config) {
47+
48+
const customDirRelative = 'custom';
49+
50+
const projectRoot = process.cwd();
51+
const customDirPath = path.resolve(projectRoot, customDirRelative);
52+
const absoluteComponentPath = path.resolve(customDirPath, componentFileName);
53+
54+
try {
55+
await fs.mkdir(customDirPath, { recursive: true });
56+
console.log(chalk.dim(`Ensured custom directory exists: ${customDirPath}`));
57+
58+
const fileContent = await generateVueContent(fieldType, context);
59+
60+
await fs.writeFile(absoluteComponentPath, fileContent, 'utf-8');
61+
console.log(chalk.green(`✅ Generated component file: ${absoluteComponentPath}`));
62+
63+
return absoluteComponentPath;
64+
65+
} catch (error) {
66+
console.error(chalk.red(`❌ Error creating component file at ${absoluteComponentPath}:`));
67+
if (!error.message.includes('template')) {
68+
console.error(error);
69+
}
70+
throw error;
71+
}
72+
}

0 commit comments

Comments
 (0)