@erwininteractive/mvc 0.5.1 → 0.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -0
- package/dist/cli.js +42 -1
- package/dist/generators/listRoutes.d.ts +22 -0
- package/dist/generators/listRoutes.js +103 -0
- package/dist/generators/makeAuth.d.ts +9 -0
- package/dist/generators/makeAuth.js +299 -0
- package/package.json +1 -1
- package/templates/appScaffold/README.md +42 -4
package/README.md
CHANGED
|
@@ -546,6 +546,50 @@ NODE_ENV=development # Environment
|
|
|
546
546
|
|
|
547
547
|
---
|
|
548
548
|
|
|
549
|
+
## Escape Hatch Documentation
|
|
550
|
+
|
|
551
|
+
### Island Pattern (EJS + React)
|
|
552
|
+
|
|
553
|
+
Use EJS for layout while adding React components where needed:
|
|
554
|
+
|
|
555
|
+
```html
|
|
556
|
+
<!-- layout.ejs -->
|
|
557
|
+
<!DOCTYPE html>
|
|
558
|
+
<html>
|
|
559
|
+
<head><title><%= title %></title></head>
|
|
560
|
+
<body>
|
|
561
|
+
<header><%- include('partial/header') %></header>
|
|
562
|
+
|
|
563
|
+
<main id="app"><%- body %></main>
|
|
564
|
+
|
|
565
|
+
<!-- React island -->
|
|
566
|
+
<div id="comment-section" data-post-id="<%= post.id %>"></div>
|
|
567
|
+
|
|
568
|
+
<footer><%- include('partial/footer') %></footer>
|
|
569
|
+
|
|
570
|
+
<script type="module" src="/src/components/CommentSection.tsx"></script>
|
|
571
|
+
</body>
|
|
572
|
+
</html>
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### API-Only Mode
|
|
576
|
+
|
|
577
|
+
Disable EJS engine for pure API responses:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
const { app } = await createMvcApp({
|
|
581
|
+
viewsPath: "src/views",
|
|
582
|
+
disableViewEngine: true, // Don't setup EJS
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
app.get("/api/users", async (req, res) => {
|
|
586
|
+
const users = await prisma.user.findMany();
|
|
587
|
+
res.json(users); // Always JSON, no EJS
|
|
588
|
+
});
|
|
589
|
+
```
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
549
593
|
## Learn More
|
|
550
594
|
|
|
551
595
|
- [Express.js Documentation](https://expressjs.com/)
|
package/dist/cli.js
CHANGED
|
@@ -7,11 +7,21 @@ const generateModel_1 = require("./generators/generateModel");
|
|
|
7
7
|
const generateController_1 = require("./generators/generateController");
|
|
8
8
|
const generateResource_1 = require("./generators/generateResource");
|
|
9
9
|
const generateWebAuthn_1 = require("./generators/generateWebAuthn");
|
|
10
|
+
const listRoutes_1 = require("./generators/listRoutes");
|
|
11
|
+
const makeAuth_1 = require("./generators/makeAuth");
|
|
10
12
|
const program = new commander_1.Command();
|
|
11
13
|
program
|
|
12
14
|
.name("erwinmvc")
|
|
13
15
|
.description("CLI for @erwininteractive/mvc framework")
|
|
14
|
-
.version("0.2.0")
|
|
16
|
+
.version("0.2.0")
|
|
17
|
+
.addHelpText("after", `
|
|
18
|
+
Examples:
|
|
19
|
+
$ erwinmvc init myapp Create a new app
|
|
20
|
+
$ erwinmvc generate resource Post Generate CRUD for Post
|
|
21
|
+
$ erwinmvc webauthn Setup passkey authentication
|
|
22
|
+
$ erwinmvc make:auth Generate authentication system
|
|
23
|
+
$ erwinmvc list:routes Show all routes
|
|
24
|
+
`);
|
|
15
25
|
// Init command - scaffold a new application
|
|
16
26
|
program
|
|
17
27
|
.command("init <dir>")
|
|
@@ -94,4 +104,35 @@ program
|
|
|
94
104
|
process.exit(1);
|
|
95
105
|
}
|
|
96
106
|
});
|
|
107
|
+
// List routes command - shows all defined routes
|
|
108
|
+
program
|
|
109
|
+
.command("list:routes")
|
|
110
|
+
.alias("lr")
|
|
111
|
+
.description("List all routes in the application")
|
|
112
|
+
.action(async () => {
|
|
113
|
+
try {
|
|
114
|
+
(0, listRoutes_1.listRoutes)();
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
// Make auth command - generate complete authentication system
|
|
122
|
+
program
|
|
123
|
+
.command("make:auth")
|
|
124
|
+
.alias("ma")
|
|
125
|
+
.description("Generate a complete authentication system")
|
|
126
|
+
.option("--without-model", "Skip generating User model")
|
|
127
|
+
.option("--session-only", "Only session-based auth (no JWT tokens)")
|
|
128
|
+
.option("--jwt-only", "Only JWT tokens (no sessions)")
|
|
129
|
+
.action(async (options) => {
|
|
130
|
+
try {
|
|
131
|
+
await (0, makeAuth_1.makeAuth)(options);
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
console.error("Error:", err instanceof Error ? err.message : err);
|
|
135
|
+
process.exit(1);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
97
138
|
program.parse();
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
interface RouteInfo {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
handler: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Parse routes from server.ts file
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseRoutes(serverPath: string): RouteInfo[];
|
|
10
|
+
/**
|
|
11
|
+
* Format routes as a table
|
|
12
|
+
*/
|
|
13
|
+
export declare function formatRoutes(routes: RouteInfo[]): string;
|
|
14
|
+
/**
|
|
15
|
+
* Get server.ts path from current directory
|
|
16
|
+
*/
|
|
17
|
+
export declare function getServerPath(cwd?: string): string;
|
|
18
|
+
/**
|
|
19
|
+
* List all routes in the current project
|
|
20
|
+
*/
|
|
21
|
+
export declare function listRoutes(cwd?: string): void;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseRoutes = parseRoutes;
|
|
7
|
+
exports.formatRoutes = formatRoutes;
|
|
8
|
+
exports.getServerPath = getServerPath;
|
|
9
|
+
exports.listRoutes = listRoutes;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
/**
|
|
13
|
+
* Parse routes from server.ts file
|
|
14
|
+
*/
|
|
15
|
+
function parseRoutes(serverPath) {
|
|
16
|
+
const routes = [];
|
|
17
|
+
if (!fs_1.default.existsSync(serverPath)) {
|
|
18
|
+
return routes;
|
|
19
|
+
}
|
|
20
|
+
const content = fs_1.default.readFileSync(serverPath, "utf-8");
|
|
21
|
+
const lines = content.split("\n");
|
|
22
|
+
for (const line of lines) {
|
|
23
|
+
// Match app.METHOD("/path", handler)
|
|
24
|
+
const routeMatch = line.match(/app\.(\w+)\s*\(\s*["']([^"']+)["']/);
|
|
25
|
+
if (routeMatch) {
|
|
26
|
+
const currentMethod = routeMatch[1].toLowerCase();
|
|
27
|
+
const currentPath = routeMatch[2];
|
|
28
|
+
let currentHandler = "";
|
|
29
|
+
// Extract handler name - look for arrow function or identifier
|
|
30
|
+
const arrowFuncMatch = line.match(/=>\s*\{/);
|
|
31
|
+
if (arrowFuncMatch) {
|
|
32
|
+
currentHandler = "<anonymous>";
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
const handlerMatch = line.match(/,\s*([^\s,)]+)/);
|
|
36
|
+
if (handlerMatch) {
|
|
37
|
+
currentHandler = handlerMatch[1].trim();
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
currentHandler = "<anonymous>";
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
routes.push({
|
|
44
|
+
method: currentMethod.toUpperCase(),
|
|
45
|
+
path: currentPath,
|
|
46
|
+
handler: currentHandler,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return routes;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format routes as a table
|
|
54
|
+
*/
|
|
55
|
+
function formatRoutes(routes) {
|
|
56
|
+
if (routes.length === 0) {
|
|
57
|
+
return "No routes found.";
|
|
58
|
+
}
|
|
59
|
+
const headers = ["Method", "Path", "Handler"];
|
|
60
|
+
const colWidths = [6, 20, 30];
|
|
61
|
+
const formatRow = (cells) => {
|
|
62
|
+
return cells.map((cell, i) => cell.padEnd(colWidths[i])).join(" | ");
|
|
63
|
+
};
|
|
64
|
+
const separator = colWidths.map(w => "-".repeat(w)).join("-+-");
|
|
65
|
+
let output = "";
|
|
66
|
+
output += formatRow(headers) + "\n";
|
|
67
|
+
output += separator + "\n";
|
|
68
|
+
for (const route of routes) {
|
|
69
|
+
output += formatRow([
|
|
70
|
+
route.method,
|
|
71
|
+
route.path,
|
|
72
|
+
route.handler,
|
|
73
|
+
]) + "\n";
|
|
74
|
+
}
|
|
75
|
+
return output;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Get server.ts path from current directory
|
|
79
|
+
*/
|
|
80
|
+
function getServerPath(cwd = process.cwd()) {
|
|
81
|
+
const possiblePaths = [
|
|
82
|
+
path_1.default.join(cwd, "src", "server.ts"),
|
|
83
|
+
path_1.default.join(cwd, "server.ts"),
|
|
84
|
+
];
|
|
85
|
+
for (const p of possiblePaths) {
|
|
86
|
+
if (fs_1.default.existsSync(p)) {
|
|
87
|
+
return p;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return possiblePaths[0];
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List all routes in the current project
|
|
94
|
+
*/
|
|
95
|
+
function listRoutes(cwd = process.cwd()) {
|
|
96
|
+
const serverPath = getServerPath(cwd);
|
|
97
|
+
const routes = parseRoutes(serverPath);
|
|
98
|
+
console.log("\nDefined Routes:\n");
|
|
99
|
+
console.log(formatRoutes(routes));
|
|
100
|
+
if (routes.length === 0) {
|
|
101
|
+
console.log("\nNo routes found. Add routes to src/server.ts");
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface MakeAuthOptions {
|
|
2
|
+
withoutModel?: boolean;
|
|
3
|
+
sessionOnly?: boolean;
|
|
4
|
+
jwtOnly?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Generate a complete authentication system (User model, AuthController, views)
|
|
8
|
+
*/
|
|
9
|
+
export declare function makeAuth(options?: MakeAuthOptions): Promise<void>;
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.makeAuth = makeAuth;
|
|
7
|
+
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
9
|
+
const paths_1 = require("./paths");
|
|
10
|
+
/**
|
|
11
|
+
* Generate a complete authentication system (User model, AuthController, views)
|
|
12
|
+
*/
|
|
13
|
+
async function makeAuth(options = {}) {
|
|
14
|
+
const targetDir = process.cwd();
|
|
15
|
+
const templateDir = path_1.default.join((0, paths_1.getTemplatesDir)(), "auth");
|
|
16
|
+
console.log("Generating authentication system...");
|
|
17
|
+
// Create auth template files if they don't exist
|
|
18
|
+
createAuthTemplates(templateDir);
|
|
19
|
+
// Copy auth templates
|
|
20
|
+
if (fs_1.default.existsSync(templateDir)) {
|
|
21
|
+
copyDirRecursive(templateDir, targetDir);
|
|
22
|
+
}
|
|
23
|
+
// Update .env with auth configuration
|
|
24
|
+
const envPath = path_1.default.join(targetDir, ".env");
|
|
25
|
+
if (fs_1.default.existsSync(envPath)) {
|
|
26
|
+
const envContent = fs_1.default.readFileSync(envPath, "utf-8");
|
|
27
|
+
if (!envContent.includes("SESSION_SECRET")) {
|
|
28
|
+
fs_1.default.appendFileSync(envPath, "\nSESSION_SECRET=\"change-me-session\"\n");
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Update .env.example
|
|
32
|
+
const envExamplePath = path_1.default.join(targetDir, ".env.example");
|
|
33
|
+
if (fs_1.default.existsSync(envExamplePath)) {
|
|
34
|
+
const envContent = fs_1.default.readFileSync(envExamplePath, "utf-8");
|
|
35
|
+
if (!envContent.includes("SESSION_SECRET")) {
|
|
36
|
+
fs_1.default.appendFileSync(envExamplePath, "\nSESSION_SECRET=\"change-me-session\"\n");
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
console.log("\nAuthentication system generated successfully!");
|
|
40
|
+
console.log(`
|
|
41
|
+
Next steps:
|
|
42
|
+
1. Run migrations: npm run db:migrate
|
|
43
|
+
2. Add auth routes to src/server.ts:
|
|
44
|
+
- import * as auth from "./controllers/AuthController";
|
|
45
|
+
- app.get("/login", auth.showLogin);
|
|
46
|
+
- app.post("/login", auth.login);
|
|
47
|
+
- app.get("/register", auth.showRegister);
|
|
48
|
+
- app.post("/register", auth.register);
|
|
49
|
+
- app.post("/logout", auth.logout);
|
|
50
|
+
`);
|
|
51
|
+
// Generate AuthController.ejs files
|
|
52
|
+
const authControllerTemplate = path_1.default.join((0, paths_1.getTemplatesDir)(), "authController.ts.ejs");
|
|
53
|
+
if (fs_1.default.existsSync(authControllerTemplate)) {
|
|
54
|
+
const destPath = path_1.default.join(targetDir, "src", "controllers", "AuthController.ts");
|
|
55
|
+
const content = fs_1.default.readFileSync(authControllerTemplate, "utf-8");
|
|
56
|
+
fs_1.default.writeFileSync(destPath, content);
|
|
57
|
+
console.log("✓ Created src/controllers/AuthController.ts");
|
|
58
|
+
}
|
|
59
|
+
// Generate EJS views
|
|
60
|
+
const viewsDir = path_1.default.join(targetDir, "src", "views", "auth");
|
|
61
|
+
fs_1.default.mkdirSync(viewsDir, { recursive: true });
|
|
62
|
+
const loginView = path_1.default.join((0, paths_1.getTemplatesDir)(), "views/auth/login.ejs");
|
|
63
|
+
if (fs_1.default.existsSync(loginView)) {
|
|
64
|
+
fs_1.default.copyFileSync(loginView, path_1.default.join(viewsDir, "login.ejs"));
|
|
65
|
+
console.log("✓ Created src/views/auth/login.ejs");
|
|
66
|
+
}
|
|
67
|
+
const registerView = path_1.default.join((0, paths_1.getTemplatesDir)(), "views/auth/register.ejs");
|
|
68
|
+
if (fs_1.default.existsSync(registerView)) {
|
|
69
|
+
fs_1.default.copyFileSync(registerView, path_1.default.join(viewsDir, "register.ejs"));
|
|
70
|
+
console.log("✓ Created src/views/auth/register.ejs");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Create auth template files if they don't exist
|
|
75
|
+
*/
|
|
76
|
+
function createAuthTemplates(templateDir) {
|
|
77
|
+
fs_1.default.mkdirSync(templateDir, { recursive: true });
|
|
78
|
+
// Create AuthController template
|
|
79
|
+
const controllerTemplate = `import { Request, Response } from "express";
|
|
80
|
+
import { hashPassword, verifyPassword, signToken, verifyToken } from "@erwininteractive/mvc";
|
|
81
|
+
import { getPrismaClient } from "@erwininteractive/mvc";
|
|
82
|
+
|
|
83
|
+
const prisma = getPrismaClient();
|
|
84
|
+
|
|
85
|
+
export async function showLogin(req: Request, res: Response) {
|
|
86
|
+
res.render("auth/login", { title: "Login" });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function showRegister(req: Request, res: Response) {
|
|
90
|
+
res.render("auth/register", { title: "Register" });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function login(req: Request, res: Response) {
|
|
94
|
+
const { email, password } = req.body;
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const user = await prisma.user.findUnique({
|
|
98
|
+
where: { email }
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!user) {
|
|
102
|
+
return res.render("auth/login", {
|
|
103
|
+
title: "Login",
|
|
104
|
+
error: "Invalid email or password"
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const isValid = await verifyPassword(password, user.hashedPassword);
|
|
109
|
+
if (!isValid) {
|
|
110
|
+
return res.render("auth/login", {
|
|
111
|
+
title: "Login",
|
|
112
|
+
error: "Invalid email or password"
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const token = signToken({ userId: user.id, email: user.email });
|
|
117
|
+
res.cookie("token", token, {
|
|
118
|
+
httpOnly: true,
|
|
119
|
+
secure: process.env.NODE_ENV === "production",
|
|
120
|
+
maxAge: 1000 * 60 * 60 * 24 // 24 hours
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
res.redirect("/");
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.error("Login error:", err);
|
|
126
|
+
res.render("auth/login", {
|
|
127
|
+
title: "Login",
|
|
128
|
+
error: "An error occurred during login"
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function register(req: Request, res: Response) {
|
|
134
|
+
const { email, password, confirmPassword } = req.body;
|
|
135
|
+
|
|
136
|
+
if (password !== confirmPassword) {
|
|
137
|
+
return res.render("auth/register", {
|
|
138
|
+
title: "Register",
|
|
139
|
+
error: "Passwords do not match"
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const existingUser = await prisma.user.findUnique({
|
|
145
|
+
where: { email }
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (existingUser) {
|
|
149
|
+
return res.render("auth/register", {
|
|
150
|
+
title: "Register",
|
|
151
|
+
error: "Email already in use"
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hashedPassword = await hashPassword(password);
|
|
156
|
+
|
|
157
|
+
await prisma.user.create({
|
|
158
|
+
data: {
|
|
159
|
+
email,
|
|
160
|
+
hashedPassword,
|
|
161
|
+
role: "user"
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
res.redirect("/login");
|
|
166
|
+
} catch (err) {
|
|
167
|
+
console.error("Registration error:", err);
|
|
168
|
+
res.render("auth/register", {
|
|
169
|
+
title: "Register",
|
|
170
|
+
error: "An error occurred during registration"
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function logout(req: Request, res: Response) {
|
|
176
|
+
res.clearCookie("token");
|
|
177
|
+
res.redirect("/login");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function requireAuth(req: Request, res: Response, next: any) {
|
|
181
|
+
const token = req.cookies?.token || req.headers.authorization?.split(" ")[1];
|
|
182
|
+
|
|
183
|
+
if (!token) {
|
|
184
|
+
return res.redirect("/login");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
const decoded = verifyToken(token);
|
|
189
|
+
req.user = decoded;
|
|
190
|
+
next();
|
|
191
|
+
} catch {
|
|
192
|
+
res.clearCookie("token");
|
|
193
|
+
res.redirect("/login");
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
`;
|
|
197
|
+
fs_1.default.writeFileSync(path_1.default.join(templateDir, "authController.ts.ejs"), controllerTemplate);
|
|
198
|
+
// Create login view
|
|
199
|
+
const loginView = `<!DOCTYPE html>
|
|
200
|
+
<html>
|
|
201
|
+
<head>
|
|
202
|
+
<title><%= title %></title>
|
|
203
|
+
<style>
|
|
204
|
+
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; }
|
|
205
|
+
input { width: 100%; padding: 8px; margin: 8px 0; }
|
|
206
|
+
button { background: #007bff; color: white; padding: 10px; border: none; cursor: pointer; }
|
|
207
|
+
.error { color: red; }
|
|
208
|
+
.link { color: #007bff; }
|
|
209
|
+
</style>
|
|
210
|
+
</head>
|
|
211
|
+
<body>
|
|
212
|
+
<h1><%= title %></h1>
|
|
213
|
+
|
|
214
|
+
<% if (error) { %>
|
|
215
|
+
<p class="error"><%= error %></p>
|
|
216
|
+
<% } %>
|
|
217
|
+
|
|
218
|
+
<form method="POST" action="/login">
|
|
219
|
+
<label>Email</label>
|
|
220
|
+
<input type="email" name="email" required>
|
|
221
|
+
|
|
222
|
+
<label>Password</label>
|
|
223
|
+
<input type="password" name="password" required>
|
|
224
|
+
|
|
225
|
+
<button type="submit">Login</button>
|
|
226
|
+
</form>
|
|
227
|
+
|
|
228
|
+
<p>Don't have an account? <a href="/register" class="link">Register</a></p>
|
|
229
|
+
</body>
|
|
230
|
+
</html>
|
|
231
|
+
`;
|
|
232
|
+
fs_1.default.writeFileSync(path_1.default.join(templateDir, "views/auth/login.ejs.ejs"), loginView);
|
|
233
|
+
// Create register view
|
|
234
|
+
const registerView = `<!DOCTYPE html>
|
|
235
|
+
<html>
|
|
236
|
+
<head>
|
|
237
|
+
<title><%= title %></title>
|
|
238
|
+
<style>
|
|
239
|
+
body { font-family: sans-serif; max-width: 400px; margin: 50px auto; }
|
|
240
|
+
input { width: 100%; padding: 8px; margin: 8px 0; }
|
|
241
|
+
button { background: #28a745; color: white; padding: 10px; border: none; cursor: pointer; }
|
|
242
|
+
.error { color: red; }
|
|
243
|
+
.link { color: #007bff; }
|
|
244
|
+
</style>
|
|
245
|
+
</head>
|
|
246
|
+
<body>
|
|
247
|
+
<h1><%= title %></h1>
|
|
248
|
+
|
|
249
|
+
<% if (error) { %>
|
|
250
|
+
<p class="error"><%= error %></p>
|
|
251
|
+
<% } %>
|
|
252
|
+
|
|
253
|
+
<form method="POST" action="/register">
|
|
254
|
+
<label>Email</label>
|
|
255
|
+
<input type="email" name="email" required>
|
|
256
|
+
|
|
257
|
+
<label>Password</label>
|
|
258
|
+
<input type="password" name="password" required>
|
|
259
|
+
|
|
260
|
+
<label>Confirm Password</label>
|
|
261
|
+
<input type="password" name="confirmPassword" required>
|
|
262
|
+
|
|
263
|
+
<button type="submit">Register</button>
|
|
264
|
+
</form>
|
|
265
|
+
|
|
266
|
+
<p>Already have an account? <a href="/login" class="link">Login</a></p>
|
|
267
|
+
</body>
|
|
268
|
+
</html>
|
|
269
|
+
`;
|
|
270
|
+
fs_1.default.writeFileSync(path_1.default.join(templateDir, "views/auth/register.ejs.ejs"), registerView);
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Recursively copy a directory
|
|
274
|
+
*/
|
|
275
|
+
function copyDirRecursive(src, dest) {
|
|
276
|
+
if (!fs_1.default.existsSync(src)) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!fs_1.default.existsSync(dest)) {
|
|
280
|
+
fs_1.default.mkdirSync(dest, { recursive: true });
|
|
281
|
+
}
|
|
282
|
+
const entries = fs_1.default.readdirSync(src, { withFileTypes: true });
|
|
283
|
+
for (const entry of entries) {
|
|
284
|
+
const srcPath = path_1.default.join(src, entry.name);
|
|
285
|
+
const destPath = path_1.default.join(dest, entry.name);
|
|
286
|
+
if (entry.isDirectory()) {
|
|
287
|
+
copyDirRecursive(srcPath, destPath);
|
|
288
|
+
}
|
|
289
|
+
else if (entry.name.endsWith(".ejs.ejs")) {
|
|
290
|
+
// Remove .ejs.ejs suffix, write render version
|
|
291
|
+
const content = fs_1.default.readFileSync(srcPath, "utf-8");
|
|
292
|
+
const targetPath = destPath.replace(/\.ejs\.ejs$/, ".ejs");
|
|
293
|
+
fs_1.default.writeFileSync(targetPath, content);
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
fs_1.default.copyFileSync(srcPath, destPath);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
package/package.json
CHANGED
|
@@ -203,6 +203,32 @@ Then run migrations again:
|
|
|
203
203
|
npx prisma migrate dev --name add-post-fields
|
|
204
204
|
```
|
|
205
205
|
|
|
206
|
+
## Full-Stack Flexibility
|
|
207
|
+
|
|
208
|
+
### Island Pattern (EJS + React)
|
|
209
|
+
|
|
210
|
+
Use EJS for layout while adding React components where needed:
|
|
211
|
+
|
|
212
|
+
```html
|
|
213
|
+
<div id="comment-section" data-post-id="<%= post.id %>"></div>
|
|
214
|
+
<script type="module" src="/src/components/CommentSection.tsx"></script>
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
### API-Only Mode
|
|
218
|
+
|
|
219
|
+
For pure API development, disable EJS:
|
|
220
|
+
|
|
221
|
+
```typescript
|
|
222
|
+
const { app } = await createMvcApp({
|
|
223
|
+
viewsPath: "src/views",
|
|
224
|
+
disableViewEngine: true,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
app.get("/api/users", async (req, res) => {
|
|
228
|
+
res.json(users); // Always JSON
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
206
232
|
### Step 5: Use in Your Code
|
|
207
233
|
|
|
208
234
|
```typescript
|
|
@@ -286,10 +312,22 @@ Link to them in your templates:
|
|
|
286
312
|
|
|
287
313
|
| Command | Description |
|
|
288
314
|
|---------|-------------|
|
|
289
|
-
| `npx
|
|
290
|
-
| `npx erwinmvc generate
|
|
291
|
-
|
|
292
|
-
|
|
315
|
+
| `npx @erwininteractive/mvc init <dir>` | Create a new app |
|
|
316
|
+
| `npx erwinmvc generate resource <name>` | Generate model + controller + views |
|
|
317
|
+
| `npx erwinmvc generate controller <name>` | Generate a CRUD controller |
|
|
318
|
+
| `npx erwinmvc generate model <name>` | Generate a database model |
|
|
319
|
+
| `npx erwinmvc webauthn` | Generate WebAuthn authentication (security keys) |
|
|
320
|
+
| `npx erwinmvc list:routes` | Show all defined routes |
|
|
321
|
+
|
|
322
|
+
### Init Options
|
|
323
|
+
|
|
324
|
+
| Option | Description |
|
|
325
|
+
|--------|-------------|
|
|
326
|
+
| `--skip-install` | Skip running npm install |
|
|
327
|
+
| `--with-database` | Include Prisma database setup |
|
|
328
|
+
| `--with-ci` | Include GitHub Actions CI workflow |
|
|
329
|
+
|
|
330
|
+
### Resource Options
|
|
293
331
|
|
|
294
332
|
## Learn More
|
|
295
333
|
|