diff --git a/adminforth/commands/cli.js b/adminforth/commands/cli.js
index a5c71261b..9c5356a77 100755
--- a/adminforth/commands/cli.js
+++ b/adminforth/commands/cli.js
@@ -6,11 +6,14 @@ const command = args[0];
import bundle from "./bundle.js";
import createApp from "./createApp/main.js";
import generateModels from "./generateModels.js";
-
+import createPlugin from "./createPlugin/main.js";
switch (command) {
case "create-app":
createApp(args);
break;
+ case "create-plugin":
+ createPlugin(args);
+ break;
case "generate-models":
generateModels();
break;
@@ -18,5 +21,7 @@ switch (command) {
bundle();
break;
default:
- console.log("Unknown command. Available commands: create-app, generate-models, bundle");
-}
\ No newline at end of file
+ console.log(
+ "Unknown command. Available commands: create-app, create-plugin, generate-models, bundle"
+ );
+}
diff --git a/adminforth/commands/createPlugin/main.js b/adminforth/commands/createPlugin/main.js
new file mode 100644
index 000000000..eb5147404
--- /dev/null
+++ b/adminforth/commands/createPlugin/main.js
@@ -0,0 +1,26 @@
+import chalk from "chalk";
+
+import {
+ parseArgumentsIntoOptions,
+ prepareWorkflow,
+ promptForMissingOptions,
+} from "./utils.js";
+
+export default async function createPlugin(args) {
+ // Step 1: Parse CLI arguments with `arg`
+ let options = parseArgumentsIntoOptions(args);
+
+ // Step 2: Ask for missing arguments via `inquirer`
+ options = await promptForMissingOptions(options);
+
+ // Step 3: Prepare a Listr-based workflow
+ const tasks = prepareWorkflow(options);
+
+ // Step 4: Run tasks
+ try {
+ await tasks.run();
+ } catch (err) {
+ console.error(chalk.red(`\nā ${err.message}\n`));
+ process.exit(1);
+ }
+}
diff --git a/adminforth/commands/createPlugin/templates/.gitignore.hbs b/adminforth/commands/createPlugin/templates/.gitignore.hbs
new file mode 100644
index 000000000..2174ce4e7
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/.gitignore.hbs
@@ -0,0 +1,3 @@
+node_modules
+custom/node_modules
+dist
\ No newline at end of file
diff --git a/adminforth/commands/createPlugin/templates/custom/tsconfig.json.hbs b/adminforth/commands/createPlugin/templates/custom/tsconfig.json.hbs
new file mode 100644
index 000000000..d313f46cb
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/custom/tsconfig.json.hbs
@@ -0,0 +1,19 @@
+{
+ "compilerOptions": {
+ "baseUrl": ".", // This should point to your project root
+ "paths": {
+ "@/*": [
+ // "node_modules/adminforth/dist/spa/src/*"
+ "../../../spa/src/*"
+ ],
+ "*": [
+ // "node_modules/adminforth/dist/spa/node_modules/*"
+ "../../../spa/node_modules/*"
+ ],
+ "@@/*": [
+ // "node_modules/adminforth/dist/spa/src/*"
+ "."
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/adminforth/commands/createPlugin/templates/index.ts.hbs b/adminforth/commands/createPlugin/templates/index.ts.hbs
new file mode 100644
index 000000000..f421fff91
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/index.ts.hbs
@@ -0,0 +1,41 @@
+import { AdminForthPlugin } from "adminforth";
+import type { IAdminForth, IHttpServer, AdminForthResourcePages, AdminForthResourceColumn, AdminForthDataTypes, AdminForthResource } from "adminforth";
+import type { PluginOptions } from './types.js';
+
+
+export default class ChatGptPlugin extends AdminForthPlugin {
+ options: PluginOptions;
+
+ constructor(options: PluginOptions) {
+ super(options, import.meta.url);
+ this.options = options;
+ }
+
+ async modifyResourceConfig(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
+ super.modifyResourceConfig(adminforth, resourceConfig);
+
+ // simply modify resourceConfig or adminforth.config. You can get access to plugin options via this.options;
+ }
+
+ validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
+ // optional method where you can safely check field types after database discovery was performed
+ }
+
+ instanceUniqueRepresentation(pluginOptions: any) : string {
+ // optional method to return unique string representation of plugin instance.
+ // Needed if plugin can have multiple instances on one resource
+ return `single`;
+ }
+
+ setupEndpoints(server: IHttpServer) {
+ server.endpoint({
+ method: 'POST',
+ path: `/plugin/${this.pluginInstanceId}/example`,
+ handler: async ({ body }) => {
+ const { name } = body;
+ return { hey: `Hello ${name}` };
+ }
+ });
+ }
+
+}
\ No newline at end of file
diff --git a/adminforth/commands/createPlugin/templates/package.json.hbs b/adminforth/commands/createPlugin/templates/package.json.hbs
new file mode 100644
index 000000000..d206e45a1
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/package.json.hbs
@@ -0,0 +1,21 @@
+{
+ "name": "{{pluginName}}",
+ "version": "1.0.1",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "type": "module",
+ "scripts": {
+ "build": "tsc && rsync -av --exclude 'node_modules' custom dist/ && npm version patch"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "description": "",
+ "devDependencies": {
+ "@types/node": "latest",
+ "typescript": "^5.7.3"
+ },
+ "dependencies": {
+ "adminforth": "latest",
+ }
+}
diff --git a/adminforth/commands/createPlugin/templates/tsconfig.json.hbs b/adminforth/commands/createPlugin/templates/tsconfig.json.hbs
new file mode 100644
index 000000000..14366b567
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/tsconfig.json.hbs
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include*/
+ "module": "node16", /* Specify what module code is generated. */
+ "outDir": "./dist", /* Specify an output folder for all emitted files. */
+ "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. */
+ "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
+ "strict": false, /* Enable all strict type-checking options. */
+ "skipLibCheck": true, /* Skip type checking all .d.ts files. */
+ },
+ "exclude": ["node_modules", "dist", "custom"], /* Exclude files from compilation. */
+ }
+
\ No newline at end of file
diff --git a/adminforth/commands/createPlugin/templates/types.ts.hbs b/adminforth/commands/createPlugin/templates/types.ts.hbs
new file mode 100644
index 000000000..062d4a794
--- /dev/null
+++ b/adminforth/commands/createPlugin/templates/types.ts.hbs
@@ -0,0 +1,3 @@
+export interface PluginOptions {
+
+}
diff --git a/adminforth/commands/createPlugin/utils.js b/adminforth/commands/createPlugin/utils.js
new file mode 100644
index 000000000..6fa0cec6a
--- /dev/null
+++ b/adminforth/commands/createPlugin/utils.js
@@ -0,0 +1,222 @@
+import arg from 'arg';
+import chalk from 'chalk';
+import fs from 'fs';
+import fse from 'fs-extra';
+import inquirer from 'inquirer';
+import path from 'path';
+import { Listr } from 'listr2';
+import { fileURLToPath } from 'url';
+import { execa } from 'execa';
+import Handlebars from 'handlebars';
+
+export function parseArgumentsIntoOptions(rawArgs) {
+ const args = arg(
+ {
+ "--plugin-name": String,
+ // you can add more flags here if needed
+ },
+ {
+ argv: rawArgs.slice(1), // skip "create-plugin"
+ }
+ );
+
+ return {
+ pluginName: args["--plugin-name"],
+ };
+}
+
+export async function promptForMissingOptions(options) {
+ const questions = [];
+
+ if (!options.pluginName) {
+ questions.push({
+ type: "input",
+ name: "pluginName",
+ message: "Please specify the name of the plugin >",
+ default: "adminforth-plugin",
+ });
+ }
+
+ const answers = await inquirer.prompt(questions);
+ return {
+ ...options,
+ pluginName: options.pluginName || answers.pluginName,
+ };
+}
+
+function checkNodeVersion(minRequiredVersion = 20) {
+ const current = process.versions.node.split(".");
+ const major = parseInt(current[0], 10);
+
+ if (isNaN(major) || major < minRequiredVersion) {
+ throw new Error(
+ `Node.js v${minRequiredVersion}+ is required. You have ${process.versions.node}. ` +
+ `Please upgrade Node.js. We recommend using nvm for managing multiple Node.js versions.`
+ );
+ }
+}
+
+function checkForExistingPackageJson() {
+ if (fs.existsSync(path.join(process.cwd(), "package.json"))) {
+ throw new Error(
+ `A package.json already exists in this directory.\n` +
+ `Please remove it or use an empty directory.`
+ );
+ }
+}
+
+function initialChecks() {
+ return [
+ {
+ title: "š Checking Node.js version...",
+ task: () => checkNodeVersion(20),
+ },
+ {
+ title: "š Validating current working directory...",
+ task: () => checkForExistingPackageJson(),
+ },
+ ];
+}
+
+function renderHBSTemplate(templatePath, data) {
+ const template = fs.readFileSync(templatePath, "utf-8");
+ const compiled = Handlebars.compile(template);
+ return compiled(data);
+}
+
+async function scaffoldProject(ctx, options, cwd) {
+ const pluginName = options.pluginName;
+
+ const filename = fileURLToPath(import.meta.url);
+ const dirname = path.dirname(filename);
+
+ // Prepare directories
+ ctx.customDir = path.join(cwd, "custom");
+ await fse.ensureDir(ctx.customDir);
+
+ // Write templated files
+ await writeTemplateFiles(dirname, cwd, {
+ pluginName,
+ });
+}
+
+async function writeTemplateFiles(dirname, cwd, options) {
+ const { pluginName } = options;
+
+ // Build a list of files to generate
+ const templateTasks = [
+ {
+ src: "tsconfig.json.hbs",
+ dest: "tsconfig.json",
+ data: {},
+ },
+ {
+ src: "package.json.hbs",
+ dest: "package.json",
+ data: { pluginName },
+ },
+ {
+ src: "index.ts.hbs",
+ dest: "index.ts",
+ data: {},
+ },
+ {
+ src: ".gitignore.hbs",
+ dest: ".gitignore",
+ data: {},
+ },
+ {
+ src: "types.ts.hbs",
+ dest: "types.ts",
+ data: {},
+ },
+ {
+ src: "custom/tsconfig.json.hbs",
+ dest: "custom/tsconfig.json",
+ data: {},
+ },
+ ];
+
+ for (const task of templateTasks) {
+ // If a condition is specified and false, skip this file
+ if (task.condition === false) continue;
+
+ const destPath = path.join(cwd, task.dest);
+ fse.ensureDirSync(path.dirname(destPath));
+
+ if (task.empty) {
+ fs.writeFileSync(destPath, "");
+ } else {
+ const templatePath = path.join(dirname, "templates", task.src);
+ const compiled = renderHBSTemplate(templatePath, task.data);
+ fs.writeFileSync(destPath, compiled);
+ }
+ }
+}
+
+async function installDependencies(ctx, cwd) {
+ const customDir = ctx.customDir;
+
+ await Promise.all([
+ await execa("npm", ["install", "--no-package-lock"], { cwd }),
+ await execa("npm", ["install"], { cwd: customDir }),
+ ]);
+}
+
+function generateFinalInstructions() {
+ let instruction = "āļø Your plugin is ready! Next steps:\n";
+
+ instruction += `
+ ${chalk.dim("// Build your plugin")}
+ ${chalk.cyan("$ npm run build")}\n`;
+
+ instruction += `
+ ${chalk.dim("// To test your plugin locally")}
+ ${chalk.cyan("$ npm link")}\n`;
+
+ instruction += `
+ ${chalk.dim("// In your AdminForth project")}
+ ${chalk.cyan("$ npm link " + chalk.italic("your-plugin-name"))}\n`;
+
+ instruction += "\nš Happy coding!";
+
+ return instruction;
+}
+
+export function prepareWorkflow(options) {
+ const cwd = process.cwd();
+ const tasks = new Listr(
+ [
+ {
+ title: "š Initial checks...",
+ task: (_, task) => task.newListr(initialChecks(), { concurrent: true }),
+ },
+ {
+ title: "š Scaffolding your plugin...",
+ task: async (ctx) => scaffoldProject(ctx, options, cwd),
+ },
+ {
+ title: "š¦ Installing dependencies...",
+ task: async (ctx) => installDependencies(ctx, cwd),
+ },
+ {
+ title: "š Preparing final instructions...",
+ task: (ctx) => {
+ console.log(
+ chalk.green(`ā
Successfully created your new AdminForth plugin!\n`)
+ );
+ console.log(generateFinalInstructions());
+ console.log("\n\n");
+ },
+ },
+ ],
+ {
+ rendererOptions: { collapseSubtasks: false },
+ concurrent: false,
+ exitOnError: true,
+ collectErrors: true,
+ }
+ );
+
+ return tasks;
+}
diff --git a/adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md b/adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md
index f5d713985..c7664f5e4 100644
--- a/adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md
+++ b/adminforth/documentation/docs/tutorial/03-Customization/08-pageInjections.md
@@ -30,110 +30,54 @@ Now create file `ApartsPie.vue` in the `custom` folder of your project:
```html title="./custom/ApartsPie.vue"
-