webspresso 0.0.97 → 0.0.98
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/.agents/ADMIN.md +153 -5
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/CLI.md +195 -1
- package/.agents/PLUGINS.md +153 -9
- package/.agents/QUEUE.md +43 -0
- package/bin/commands/add-esbuild.js +80 -0
- package/bin/commands/add-tailwind.js +76 -31
- package/bin/commands/analytics-prune.js +57 -0
- package/bin/commands/api.js +207 -15
- package/bin/commands/build.js +99 -0
- package/bin/commands/dev.js +64 -35
- package/bin/commands/doctor.js +26 -0
- package/bin/commands/email-prune.js +47 -0
- package/bin/commands/make-job.js +86 -0
- package/bin/commands/make-model.js +250 -0
- package/bin/commands/new.js +45 -28
- package/bin/commands/openapi.js +102 -0
- package/bin/commands/page.js +6 -3
- package/bin/commands/queue-prune.js +59 -0
- package/bin/commands/routes.js +52 -0
- package/bin/commands/service.js +6 -3
- package/bin/commands/typegen.js +106 -0
- package/bin/commands/upload-prune.js +79 -0
- package/bin/webspresso.js +22 -0
- package/core/applySchema.js +13 -6
- package/core/auth/hash.js +21 -12
- package/core/compression/middleware.js +23 -17
- package/core/openapi/build-from-api-routes.js +160 -29
- package/core/orm/cache/fingerprint.js +15 -1
- package/core/orm/cache/memory-provider.js +27 -2
- package/core/orm/index.js +12 -7
- package/core/orm/model.js +49 -7
- package/core/queue/adapters/database.js +94 -0
- package/core/queue/adapters/memory.js +62 -0
- package/core/queue/adapters/redis.js +80 -0
- package/core/queue/manager.js +123 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +660 -10
- package/index.js +44 -96
- package/package.json +1 -1
- package/plugins/admin-panel/api.js +279 -17
- package/plugins/admin-panel/app.js +114 -22
- package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
- package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
- package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +10 -5
- package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
- package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
- package/plugins/admin-panel/client/parts/08-record-list.js +243 -23
- package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
- package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
- package/plugins/admin-panel/core/admin-module.js +13 -7
- package/plugins/admin-panel/core/api-extensions.js +30 -2
- package/plugins/admin-panel/core/registry.js +96 -16
- package/plugins/admin-panel/index.js +96 -0
- package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
- package/plugins/admin-panel/modules/admin-users.js +16 -1
- package/plugins/admin-panel/modules/bulk-actions.js +13 -5
- package/plugins/admin-panel/modules/custom-pages.js +492 -3
- package/plugins/admin-panel/modules/dashboard.js +283 -0
- package/plugins/admin-panel/modules/menu.js +56 -18
- package/plugins/admin-panel/modules/user-management.js +24 -6
- package/plugins/audit-log/admin-component.js +11 -0
- package/plugins/audit-log/api-handlers.js +34 -0
- package/plugins/audit-log/index.js +129 -3
- package/plugins/audit-log/purge.js +60 -10
- package/plugins/dashboard/app.js +152 -27
- package/plugins/dashboard/index.js +143 -39
- package/plugins/dashboard/styles.js +78 -0
- package/plugins/data-exchange/export-xlsx.js +9 -2
- package/plugins/data-exchange/import.js +9 -2
- package/plugins/data-exchange/index.js +6 -3
- package/plugins/email/index.js +132 -2
- package/plugins/email/purge.js +60 -10
- package/plugins/index.js +4 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +11 -0
- package/plugins/schema-explorer/admin-component.js +714 -0
- package/plugins/schema-explorer/relations-graph.js +205 -0
- package/plugins/schema-explorer.js +248 -69
- package/plugins/site-analytics/admin-component.js +23 -0
- package/plugins/site-analytics/api-handlers.js +42 -0
- package/plugins/site-analytics/index.js +147 -2
- package/plugins/site-analytics/purge.js +168 -0
- package/plugins/swagger.js +261 -13
- package/plugins/upload/index.js +26 -0
- package/plugins/upload/orphan-pruner.js +292 -0
- package/src/api/api-loader.js +3 -1
- package/src/bundle/index.js +517 -0
- package/src/bundle/manifest.js +165 -0
- package/src/dev/banner.js +296 -0
- package/src/dev/colors.js +70 -0
- package/src/dev/index.js +62 -0
- package/src/dev/profiler.js +354 -0
- package/src/dev/typegen.js +497 -0
- package/src/dev-reload/index.js +611 -0
- package/src/file-router.js +210 -62
- package/src/helpers.js +958 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/server.js +629 -63
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Add esbuild Command
|
|
3
|
+
* Add esbuild client bundling and incremental watch to the project
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
function registerCommand(program) {
|
|
10
|
+
program
|
|
11
|
+
.command('add esbuild')
|
|
12
|
+
.alias('add:esbuild')
|
|
13
|
+
.description('Add esbuild client bundling and incremental watch to the project')
|
|
14
|
+
.action(async () => {
|
|
15
|
+
if (!fs.existsSync('package.json')) {
|
|
16
|
+
console.error('❌ Not a Webspresso project! Run this command in your project directory.');
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log('\n⚡ Adding esbuild client bundler to your project...\n');
|
|
21
|
+
|
|
22
|
+
// Read package.json
|
|
23
|
+
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
24
|
+
packageJson.devDependencies = packageJson.devDependencies || {};
|
|
25
|
+
packageJson.devDependencies['esbuild'] = '^0.25.0';
|
|
26
|
+
|
|
27
|
+
packageJson.scripts = packageJson.scripts || {};
|
|
28
|
+
packageJson.scripts['build:js'] = 'esbuild src/client/main.js --bundle --outdir=public/js --minify --sourcemap';
|
|
29
|
+
packageJson.scripts['watch:js'] = 'esbuild src/client/main.js --bundle --outdir=public/js --sourcemap --watch';
|
|
30
|
+
|
|
31
|
+
fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
|
|
32
|
+
console.log('✅ Updated package.json (esbuild devDependency and build:js / watch:js scripts)');
|
|
33
|
+
|
|
34
|
+
// Create src/client directory
|
|
35
|
+
if (!fs.existsSync('src/client')) {
|
|
36
|
+
fs.mkdirSync('src/client', { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Create src/client/main.js if not exists
|
|
40
|
+
const mainJsPath = path.join('src', 'client', 'main.js');
|
|
41
|
+
if (!fs.existsSync(mainJsPath)) {
|
|
42
|
+
const clientStarterCode = `// Client-side JavaScript entry point
|
|
43
|
+
console.log('⚡ Webspresso client script loaded');
|
|
44
|
+
|
|
45
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
46
|
+
// Client interactivity
|
|
47
|
+
});
|
|
48
|
+
`;
|
|
49
|
+
fs.writeFileSync(mainJsPath, clientStarterCode);
|
|
50
|
+
console.log('✅ Created src/client/main.js');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create public/js directory
|
|
54
|
+
if (!fs.existsSync(path.join('public', 'js'))) {
|
|
55
|
+
fs.mkdirSync(path.join('public', 'js'), { recursive: true });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Update layout.njk to include script if not present
|
|
59
|
+
const layoutPath = path.join('views', 'layout.njk');
|
|
60
|
+
if (fs.existsSync(layoutPath)) {
|
|
61
|
+
let layoutContent = fs.readFileSync(layoutPath, 'utf-8');
|
|
62
|
+
if (!layoutContent.includes('/js/main.js')) {
|
|
63
|
+
layoutContent = layoutContent.replace(
|
|
64
|
+
/(<\/head>)/,
|
|
65
|
+
' <script defer src="/js/main.js"></script>\n$1'
|
|
66
|
+
);
|
|
67
|
+
fs.writeFileSync(layoutPath, layoutContent);
|
|
68
|
+
console.log('✅ Updated views/layout.njk (<script defer src="/js/main.js"></script>)');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log('\n✅ esbuild setup complete!\n');
|
|
73
|
+
console.log('Next steps:');
|
|
74
|
+
console.log(' npm install');
|
|
75
|
+
console.log(' npm run build:js');
|
|
76
|
+
console.log(' npm run dev\n');
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { registerCommand };
|
|
@@ -10,14 +10,26 @@ const { execSync } = require('child_process');
|
|
|
10
10
|
function registerCommand(program) {
|
|
11
11
|
program
|
|
12
12
|
.command('add tailwind')
|
|
13
|
+
.alias('add:tailwind')
|
|
13
14
|
.description('Add Tailwind CSS to the project with build process')
|
|
14
|
-
.
|
|
15
|
+
.option('--v4', 'Use Tailwind CSS v4 (Oxide engine, LightningCSS, zero postcss config)')
|
|
16
|
+
.option('--v3', 'Use Tailwind CSS v3 (legacy PostCSS engine)')
|
|
17
|
+
.option('-v, --tailwind-version <version>', 'Tailwind version (3 or 4)', '3')
|
|
18
|
+
.option('--upgrade', 'Upgrade existing Tailwind v3 project to Tailwind v4')
|
|
19
|
+
.action(async (...actionArgs) => {
|
|
20
|
+
const cmd = actionArgs[actionArgs.length - 1];
|
|
21
|
+
const options = (cmd && typeof cmd.opts === 'function')
|
|
22
|
+
? cmd.opts()
|
|
23
|
+
: (actionArgs.find(a => a && typeof a === 'object') || {});
|
|
24
|
+
|
|
15
25
|
if (!fs.existsSync('package.json')) {
|
|
16
26
|
console.error('❌ Not a Webspresso project! Run this command in your project directory.');
|
|
17
27
|
process.exit(1);
|
|
18
28
|
}
|
|
19
29
|
|
|
20
|
-
|
|
30
|
+
const isV4 = Boolean(options.v4 || options.upgrade || options.tailwindVersion === '4');
|
|
31
|
+
|
|
32
|
+
console.log(`\n🎨 Adding Tailwind CSS ${isV4 ? 'v4 (Oxide engine, zero-config)' : 'v3'} to your project...\n`);
|
|
21
33
|
|
|
22
34
|
// Read package.json
|
|
23
35
|
const packageJson = JSON.parse(fs.readFileSync('package.json', 'utf-8'));
|
|
@@ -27,17 +39,26 @@ function registerCommand(program) {
|
|
|
27
39
|
packageJson.devDependencies = {};
|
|
28
40
|
}
|
|
29
41
|
|
|
30
|
-
packageJson.devDependencies['tailwindcss'] = '^3.4.1';
|
|
31
|
-
packageJson.devDependencies['postcss'] = '^8.4.35';
|
|
32
|
-
packageJson.devDependencies['autoprefixer'] = '^10.4.17';
|
|
33
|
-
|
|
34
|
-
// Add build scripts
|
|
35
42
|
if (!packageJson.scripts) {
|
|
36
43
|
packageJson.scripts = {};
|
|
37
44
|
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
45
|
+
|
|
46
|
+
if (isV4) {
|
|
47
|
+
packageJson.devDependencies['@tailwindcss/cli'] = '^4.0.0';
|
|
48
|
+
packageJson.devDependencies['tailwindcss'] = '^4.0.0';
|
|
49
|
+
delete packageJson.devDependencies['postcss'];
|
|
50
|
+
delete packageJson.devDependencies['autoprefixer'];
|
|
51
|
+
|
|
52
|
+
packageJson.scripts['build:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --minify';
|
|
53
|
+
packageJson.scripts['watch:css'] = '@tailwindcss/cli -i ./src/input.css -o ./public/css/style.css --watch';
|
|
54
|
+
} else {
|
|
55
|
+
packageJson.devDependencies['tailwindcss'] = '^3.4.1';
|
|
56
|
+
packageJson.devDependencies['postcss'] = '^8.4.35';
|
|
57
|
+
packageJson.devDependencies['autoprefixer'] = '^10.4.17';
|
|
58
|
+
|
|
59
|
+
packageJson.scripts['build:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --minify';
|
|
60
|
+
packageJson.scripts['watch:css'] = 'tailwindcss -i ./src/input.css -o ./public/css/style.css --watch';
|
|
61
|
+
}
|
|
41
62
|
|
|
42
63
|
// Update dev script to include CSS watch
|
|
43
64
|
if (packageJson.scripts.dev) {
|
|
@@ -50,24 +71,48 @@ function registerCommand(program) {
|
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
fs.writeFileSync('package.json', JSON.stringify(packageJson, null, 2) + '\n');
|
|
53
|
-
console.log(
|
|
74
|
+
console.log(`✅ Updated package.json (${isV4 ? 'Tailwind v4' : 'Tailwind v3'})`);
|
|
54
75
|
|
|
55
76
|
// Create src directory if it doesn't exist
|
|
56
77
|
if (!fs.existsSync('src')) {
|
|
57
78
|
fs.mkdirSync('src', { recursive: true });
|
|
58
79
|
}
|
|
59
80
|
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
if (isV4) {
|
|
82
|
+
// Create v4 input.css
|
|
83
|
+
const inputCss = `@import "tailwindcss";
|
|
84
|
+
|
|
85
|
+
@source "../pages/**/*.{njk,js}";
|
|
86
|
+
@source "../views/**/*.njk";
|
|
87
|
+
@source "../src/**/*.js";
|
|
88
|
+
`;
|
|
89
|
+
fs.writeFileSync('src/input.css', inputCss);
|
|
90
|
+
console.log('✅ Created src/input.css (@import "tailwindcss"; + @source directives)');
|
|
91
|
+
|
|
92
|
+
// If upgrading from v3, clean up obsolete configs
|
|
93
|
+
if (fs.existsSync('postcss.config.js')) {
|
|
94
|
+
try {
|
|
95
|
+
fs.unlinkSync('postcss.config.js');
|
|
96
|
+
console.log('🧹 Removed obsolete postcss.config.js (not needed in v4)');
|
|
97
|
+
} catch (_) {}
|
|
98
|
+
}
|
|
99
|
+
if (fs.existsSync('tailwind.config.js')) {
|
|
100
|
+
try {
|
|
101
|
+
fs.renameSync('tailwind.config.js', 'tailwind.config.js.backup');
|
|
102
|
+
console.log('ℹ️ Renamed tailwind.config.js to tailwind.config.js.backup (v4 is CSS-first)');
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
// Create v3 input.css
|
|
107
|
+
const inputCss = `@tailwind base;
|
|
62
108
|
@tailwind components;
|
|
63
109
|
@tailwind utilities;
|
|
64
110
|
`;
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
const tailwindConfig = `/** @type {import('tailwindcss').Config} */
|
|
111
|
+
fs.writeFileSync('src/input.css', inputCss);
|
|
112
|
+
console.log('✅ Created src/input.css');
|
|
113
|
+
|
|
114
|
+
// Create tailwind.config.js
|
|
115
|
+
const tailwindConfig = `/** @type {import('tailwindcss').Config} */
|
|
71
116
|
module.exports = {
|
|
72
117
|
content: [
|
|
73
118
|
'./pages/**/*.{njk,js}',
|
|
@@ -80,21 +125,20 @@ module.exports = {
|
|
|
80
125
|
plugins: [],
|
|
81
126
|
}
|
|
82
127
|
`;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const postcssConfig = `module.exports = {
|
|
128
|
+
fs.writeFileSync('tailwind.config.js', tailwindConfig);
|
|
129
|
+
console.log('✅ Created tailwind.config.js');
|
|
130
|
+
|
|
131
|
+
// Create postcss.config.js
|
|
132
|
+
const postcssConfig = `module.exports = {
|
|
89
133
|
plugins: {
|
|
90
134
|
tailwindcss: {},
|
|
91
135
|
autoprefixer: {},
|
|
92
136
|
},
|
|
93
137
|
}
|
|
94
138
|
`;
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
139
|
+
fs.writeFileSync('postcss.config.js', postcssConfig);
|
|
140
|
+
console.log('✅ Created postcss.config.js');
|
|
141
|
+
}
|
|
98
142
|
|
|
99
143
|
// Check if layout.njk exists and update it (before creating CSS)
|
|
100
144
|
const layoutPath = 'views/layout.njk';
|
|
@@ -128,9 +172,10 @@ module.exports = {
|
|
|
128
172
|
fs.writeFileSync('public/css/style.css', '/* Run npm run build:css */\n');
|
|
129
173
|
console.log('✅ Created public/css/style.css');
|
|
130
174
|
|
|
131
|
-
// Try to build CSS if tailwindcss is already installed
|
|
175
|
+
// Try to build CSS if tailwindcss / @tailwindcss/cli is already installed
|
|
132
176
|
const tailwindBin = path.join(process.cwd(), 'node_modules', '.bin', 'tailwindcss');
|
|
133
|
-
|
|
177
|
+
const tailwindCliBin = path.join(process.cwd(), 'node_modules', '.bin', '@tailwindcss/cli');
|
|
178
|
+
if (fs.existsSync(tailwindBin) || fs.existsSync(tailwindCliBin)) {
|
|
134
179
|
try {
|
|
135
180
|
console.log('\n🎨 Building Tailwind CSS from your templates...');
|
|
136
181
|
execSync('npm run build:css', { stdio: 'inherit', cwd: process.cwd() });
|
|
@@ -139,7 +184,7 @@ module.exports = {
|
|
|
139
184
|
console.log('\n⚠️ CSS build failed. Run "npm run build:css" manually.\n');
|
|
140
185
|
}
|
|
141
186
|
} else {
|
|
142
|
-
console.log(
|
|
187
|
+
console.log(`\n✅ Tailwind CSS ${isV4 ? 'v4' : 'v3'} added successfully!\n`);
|
|
143
188
|
console.log('Next steps:');
|
|
144
189
|
console.log(' npm install');
|
|
145
190
|
console.log(' npm run build:css');
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prune old site analytics records (CLI)
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { loadDbConfig, createDbInstance } = require('../utils/db');
|
|
6
|
+
|
|
7
|
+
function registerCommand(program) {
|
|
8
|
+
program
|
|
9
|
+
.command('analytics:prune')
|
|
10
|
+
.description('Delete analytics page views and client errors older than the given retention window')
|
|
11
|
+
.option('--days <n>', 'Delete page views older than this many days', '90')
|
|
12
|
+
.option('--error-days <n>', 'Delete client errors older than this many days', '30')
|
|
13
|
+
.option('--table <name>', 'Page views table name', 'analytics_page_views')
|
|
14
|
+
.option('--errors-table <name>', 'Client errors table name', 'analytics_client_errors')
|
|
15
|
+
.option('-e, --env <environment>', 'Environment (development, production)', 'development')
|
|
16
|
+
.option('-c, --config <path>', 'Path to database config file')
|
|
17
|
+
.action(async (options) => {
|
|
18
|
+
const days = parseInt(options.days, 10);
|
|
19
|
+
if (Number.isNaN(days) || days < 1) {
|
|
20
|
+
console.error('❌ --days must be a positive integer');
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const errorDays = parseInt(options.errorDays, 10);
|
|
25
|
+
if (Number.isNaN(errorDays) || errorDays < 1) {
|
|
26
|
+
console.error('❌ --error-days must be a positive integer');
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { config, path: configPath } = loadDbConfig(options.config);
|
|
31
|
+
console.log(`\n📦 Using config: ${configPath}`);
|
|
32
|
+
console.log(` Environment: ${options.env}\n`);
|
|
33
|
+
|
|
34
|
+
const knex = await createDbInstance(config, options.env);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const { purgeAnalyticsData } = require('../../plugins/site-analytics/purge');
|
|
38
|
+
const result = await purgeAnalyticsData(knex, {
|
|
39
|
+
days,
|
|
40
|
+
errorDays,
|
|
41
|
+
tableName: options.table,
|
|
42
|
+
errorsTableName: options.errorsTable,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
console.log(`✅ Analytics purge completed:`);
|
|
46
|
+
console.log(` - Page views deleted: ${result.viewsDeleted} (cutoff: ${result.viewsCutoff})`);
|
|
47
|
+
console.log(` - Client errors deleted: ${result.errorsDeleted} (cutoff: ${result.errorsCutoff})\n`);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
console.error('❌ analytics:prune failed:', err.message);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
} finally {
|
|
52
|
+
await knex.destroy();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
module.exports = { registerCommand };
|
package/bin/commands/api.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* API Command
|
|
5
|
-
* Add a new API endpoint with modern Webspresso schema & handler contract
|
|
5
|
+
* Add a new API endpoint or full REST CRUD suite with modern Webspresso schema & handler contract
|
|
6
6
|
* @module bin/commands/api
|
|
7
7
|
*/
|
|
8
8
|
|
|
@@ -18,6 +18,31 @@ const path = require('path');
|
|
|
18
18
|
|
|
19
19
|
const VALID_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
20
20
|
|
|
21
|
+
function singularize(word) {
|
|
22
|
+
if (!word) return '';
|
|
23
|
+
const lower = word.toLowerCase();
|
|
24
|
+
if (lower.endsWith('ies')) {
|
|
25
|
+
return lower.slice(0, -3) + 'y';
|
|
26
|
+
}
|
|
27
|
+
if (lower.endsWith('ses')) {
|
|
28
|
+
return lower.slice(0, -2);
|
|
29
|
+
}
|
|
30
|
+
if (lower.endsWith('s') && !lower.endsWith('ss')) {
|
|
31
|
+
return lower.slice(0, -1);
|
|
32
|
+
}
|
|
33
|
+
return lower;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function toPascalCase(str) {
|
|
37
|
+
if (!str) return '';
|
|
38
|
+
const clean = str.replace(/[^a-zA-Z0-9]/g, ' ');
|
|
39
|
+
return clean
|
|
40
|
+
.split(' ')
|
|
41
|
+
.filter(Boolean)
|
|
42
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
43
|
+
.join('');
|
|
44
|
+
}
|
|
45
|
+
|
|
21
46
|
function parseRouteAndMethod(rawInput, optionMethod) {
|
|
22
47
|
let route = String(rawInput || '').trim();
|
|
23
48
|
let method = optionMethod ? String(optionMethod).toUpperCase() : null;
|
|
@@ -45,18 +70,160 @@ function parseRouteAndMethod(rawInput, optionMethod) {
|
|
|
45
70
|
};
|
|
46
71
|
}
|
|
47
72
|
|
|
73
|
+
function generateCrudFiles(apiDir, route, modelName, middlewareStr) {
|
|
74
|
+
const cleanRoute = route.replace(/^\/+|\/+$/g, '');
|
|
75
|
+
const baseDir = path.join(apiDir, cleanRoute);
|
|
76
|
+
const detailDir = path.join(baseDir, '[id]');
|
|
77
|
+
fs.mkdirSync(detailDir, { recursive: true });
|
|
78
|
+
|
|
79
|
+
const files = [
|
|
80
|
+
{
|
|
81
|
+
path: path.join(apiDir, `${cleanRoute}.get.js`),
|
|
82
|
+
label: `GET /api/${cleanRoute} (List)`,
|
|
83
|
+
content: `'use strict';
|
|
84
|
+
|
|
85
|
+
const { defineApi } = require('webspresso');
|
|
86
|
+
|
|
87
|
+
module.exports = defineApi({
|
|
88
|
+
schema: ({ z }) => ({
|
|
89
|
+
query: z.object({
|
|
90
|
+
page: z.coerce.number().optional().default(1),
|
|
91
|
+
limit: z.coerce.number().optional().default(20),
|
|
92
|
+
}),
|
|
93
|
+
}),${middlewareStr}
|
|
94
|
+
handler: async (req, res) => {
|
|
95
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
96
|
+
const { page, limit } = req.input.query;
|
|
97
|
+
const items = repo ? await repo.query().paginate({ page, limit }) : [];
|
|
98
|
+
return res.json(items);
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
`,
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
path: path.join(apiDir, `${cleanRoute}.post.js`),
|
|
105
|
+
label: `POST /api/${cleanRoute} (Create)`,
|
|
106
|
+
content: `'use strict';
|
|
107
|
+
|
|
108
|
+
const { defineApi } = require('webspresso');
|
|
109
|
+
|
|
110
|
+
module.exports = defineApi({
|
|
111
|
+
schema: ({ z }) => ({
|
|
112
|
+
body: z.object({
|
|
113
|
+
title: z.string().min(1).optional(),
|
|
114
|
+
}).passthrough(),
|
|
115
|
+
}),${middlewareStr}
|
|
116
|
+
handler: async (req, res) => {
|
|
117
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
118
|
+
const created = repo ? await repo.create(req.input.body) : req.input.body;
|
|
119
|
+
return res.status(201).json(created);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
`,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
path: path.join(detailDir, '[id].get.js'),
|
|
126
|
+
label: `GET /api/${cleanRoute}/:id (Detail)`,
|
|
127
|
+
content: `'use strict';
|
|
128
|
+
|
|
129
|
+
const { defineApi } = require('webspresso');
|
|
130
|
+
|
|
131
|
+
module.exports = defineApi({
|
|
132
|
+
schema: ({ z }) => ({
|
|
133
|
+
params: z.object({
|
|
134
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
135
|
+
}),
|
|
136
|
+
}),${middlewareStr}
|
|
137
|
+
handler: async (req, res) => {
|
|
138
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
139
|
+
const item = repo ? await repo.findById(req.params.id) : null;
|
|
140
|
+
if (!item) {
|
|
141
|
+
return res.status(404).json({ error: '${modelName} not found' });
|
|
142
|
+
}
|
|
143
|
+
return res.json(item);
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
`,
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
path: path.join(detailDir, '[id].patch.js'),
|
|
150
|
+
label: `PATCH /api/${cleanRoute}/:id (Update)`,
|
|
151
|
+
content: `'use strict';
|
|
152
|
+
|
|
153
|
+
const { defineApi } = require('webspresso');
|
|
154
|
+
|
|
155
|
+
module.exports = defineApi({
|
|
156
|
+
schema: ({ z }) => ({
|
|
157
|
+
params: z.object({
|
|
158
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
159
|
+
}),
|
|
160
|
+
body: z.record(z.unknown()).optional(),
|
|
161
|
+
}),${middlewareStr}
|
|
162
|
+
handler: async (req, res) => {
|
|
163
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
164
|
+
const updated = repo ? await repo.update(req.params.id, req.input.body || {}) : null;
|
|
165
|
+
if (!updated) {
|
|
166
|
+
return res.status(404).json({ error: '${modelName} not found' });
|
|
167
|
+
}
|
|
168
|
+
return res.json(updated);
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
`,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
path: path.join(detailDir, '[id].delete.js'),
|
|
175
|
+
label: `DELETE /api/${cleanRoute}/:id (Delete)`,
|
|
176
|
+
content: `'use strict';
|
|
177
|
+
|
|
178
|
+
const { defineApi } = require('webspresso');
|
|
179
|
+
|
|
180
|
+
module.exports = defineApi({
|
|
181
|
+
schema: ({ z }) => ({
|
|
182
|
+
params: z.object({
|
|
183
|
+
id: z.coerce.number().or(z.string().min(1)),
|
|
184
|
+
}),
|
|
185
|
+
}),${middlewareStr}
|
|
186
|
+
handler: async (req, res) => {
|
|
187
|
+
const repo = req.db ? req.db.getRepository('${modelName}') : null;
|
|
188
|
+
if (repo) {
|
|
189
|
+
await repo.delete(req.params.id);
|
|
190
|
+
}
|
|
191
|
+
return res.status(204).end();
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
`,
|
|
195
|
+
},
|
|
196
|
+
];
|
|
197
|
+
|
|
198
|
+
for (const f of files) {
|
|
199
|
+
fs.writeFileSync(f.path, f.content, 'utf8');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return files;
|
|
203
|
+
}
|
|
204
|
+
|
|
48
205
|
function registerCommand(program) {
|
|
49
206
|
program
|
|
50
207
|
.command('api [route]')
|
|
208
|
+
.alias('make:api')
|
|
209
|
+
.alias('api:make')
|
|
51
210
|
.description('Add a new API endpoint to pages/api/ with schema validation and handler')
|
|
52
211
|
.option('-m, --method <method>', 'HTTP method (GET, POST, PUT, PATCH, DELETE)', 'GET')
|
|
212
|
+
.option('--crud', 'Generate full REST CRUD endpoints (list, create, get, update, delete)')
|
|
213
|
+
.option('--model <model>', 'Associated ORM model name for repository calls')
|
|
53
214
|
.option('--auth', 'Protect with session auth middleware')
|
|
54
215
|
.option('--jwt', 'Protect with JWT bearer middleware')
|
|
216
|
+
.option('--root <dir>', 'Root directory of the project', process.cwd())
|
|
55
217
|
.action(async (routeArg, options) => {
|
|
56
|
-
const
|
|
218
|
+
const rootDir = path.resolve(options.root || process.cwd());
|
|
219
|
+
const pagesDir = path.join(rootDir, 'pages');
|
|
57
220
|
if (!fs.existsSync(pagesDir)) {
|
|
58
|
-
|
|
59
|
-
|
|
221
|
+
fs.mkdirSync(pagesDir, { recursive: true });
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const apiDir = path.join(pagesDir, 'api');
|
|
225
|
+
if (!fs.existsSync(apiDir)) {
|
|
226
|
+
fs.mkdirSync(apiDir, { recursive: true });
|
|
60
227
|
}
|
|
61
228
|
|
|
62
229
|
let route;
|
|
@@ -83,17 +250,39 @@ function registerCommand(program) {
|
|
|
83
250
|
message: 'HTTP method:',
|
|
84
251
|
choices: VALID_METHODS,
|
|
85
252
|
default: options.method ? options.method.toUpperCase() : 'GET',
|
|
253
|
+
when: () => !options.crud,
|
|
86
254
|
},
|
|
87
255
|
]);
|
|
88
256
|
|
|
89
|
-
const parsed = parseRouteAndMethod(answers.route, answers.method);
|
|
257
|
+
const parsed = parseRouteAndMethod(answers.route, answers.method || options.method);
|
|
90
258
|
route = parsed.route;
|
|
91
259
|
method = parsed.method;
|
|
92
260
|
}
|
|
93
261
|
|
|
262
|
+
const middlewares = [];
|
|
263
|
+
if (options.auth) middlewares.push("'auth'");
|
|
264
|
+
if (options.jwt) middlewares.push("'jwt'");
|
|
265
|
+
const middlewareStr = middlewares.length > 0 ? `\n middleware: [${middlewares.join(', ')}],` : '';
|
|
266
|
+
|
|
267
|
+
// CRUD Generation Mode
|
|
268
|
+
if (options.crud) {
|
|
269
|
+
const lastSegment = route.split('/').filter(Boolean).pop() || route;
|
|
270
|
+
const singular = singularize(lastSegment);
|
|
271
|
+
const modelName = options.model ? String(options.model).trim() : toPascalCase(singular);
|
|
272
|
+
|
|
273
|
+
const createdFiles = generateCrudFiles(apiDir, route, modelName, middlewareStr);
|
|
274
|
+
console.log(`\n✅ Generated REST CRUD API endpoints for "${modelName}" in pages/api/${route}:`);
|
|
275
|
+
for (const cf of createdFiles) {
|
|
276
|
+
console.log(` - ${cf.label} → ${path.relative(process.cwd(), cf.path)}`);
|
|
277
|
+
}
|
|
278
|
+
console.log('');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Single Route Generation Mode
|
|
94
283
|
const methodUpper = String(method).toUpperCase();
|
|
95
284
|
const methodLower = methodUpper.toLowerCase();
|
|
96
|
-
const routePath = path.join(
|
|
285
|
+
const routePath = path.join(apiDir, route);
|
|
97
286
|
const dirPath = path.dirname(routePath);
|
|
98
287
|
const fileName = path.basename(routePath);
|
|
99
288
|
|
|
@@ -101,11 +290,6 @@ function registerCommand(program) {
|
|
|
101
290
|
|
|
102
291
|
const apiFile = path.join(dirPath, `${fileName}.${methodLower}.js`);
|
|
103
292
|
|
|
104
|
-
const middlewares = [];
|
|
105
|
-
if (options.auth) middlewares.push("'auth'");
|
|
106
|
-
if (options.jwt) middlewares.push("'jwt'");
|
|
107
|
-
const middlewareStr = middlewares.length > 0 ? `\n middleware: [${middlewares.join(', ')}],` : '';
|
|
108
|
-
|
|
109
293
|
const hasParams = route.includes('[');
|
|
110
294
|
const hasBody = ['POST', 'PUT', 'PATCH'].includes(methodUpper);
|
|
111
295
|
|
|
@@ -114,10 +298,12 @@ function registerCommand(program) {
|
|
|
114
298
|
/**
|
|
115
299
|
* ${methodUpper} /api/${route}
|
|
116
300
|
*/
|
|
117
|
-
|
|
301
|
+
const { defineApi } = require('webspresso');
|
|
302
|
+
|
|
303
|
+
module.exports = defineApi({
|
|
118
304
|
// Input validation schema (Zod)
|
|
119
305
|
schema: ({ z }) => ({
|
|
120
|
-
${hasParams ? 'params: z.object({ id: z.string().min(1) }),\n ' : ''}${hasBody ? 'body: z.object({\n // Define body schema here\n }),\n ' : ''}query: z.object({\n // Define query params here\n }),
|
|
306
|
+
${hasParams ? 'params: z.object({ id: z.coerce.number().or(z.string().min(1)) }),\n ' : ''}${hasBody ? 'body: z.object({\n // Define body schema here\n }).passthrough(),\n ' : ''}query: z.object({\n // Define query params here\n }),
|
|
121
307
|
}),${middlewareStr}
|
|
122
308
|
|
|
123
309
|
// Request Handler
|
|
@@ -131,7 +317,7 @@ module.exports = {
|
|
|
131
317
|
timestamp: new Date().toISOString(),
|
|
132
318
|
});
|
|
133
319
|
},
|
|
134
|
-
};
|
|
320
|
+
});
|
|
135
321
|
`;
|
|
136
322
|
|
|
137
323
|
fs.writeFileSync(apiFile, apiContent, 'utf8');
|
|
@@ -139,4 +325,10 @@ module.exports = {
|
|
|
139
325
|
});
|
|
140
326
|
}
|
|
141
327
|
|
|
142
|
-
module.exports = {
|
|
328
|
+
module.exports = {
|
|
329
|
+
registerCommand,
|
|
330
|
+
parseRouteAndMethod,
|
|
331
|
+
singularize,
|
|
332
|
+
toPascalCase,
|
|
333
|
+
generateCrudFiles,
|
|
334
|
+
};
|