webspresso 0.0.66 → 0.0.68
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 +74 -1
- package/bin/commands/doctor.js +23 -0
- package/bin/commands/new.js +106 -15
- package/core/orm/migrations/scaffold.js +6 -0
- package/core/orm/schema-helpers.js +19 -0
- package/core/orm/types.js +1 -1
- package/index.d.ts +36 -0
- package/index.js +19 -1
- package/package.json +11 -4
- package/plugins/admin-panel/api.js +5 -5
- package/plugins/admin-panel/components.js +184 -0
- package/plugins/admin-panel/core/registry.js +1 -0
- package/plugins/admin-panel/field-renderers/file-upload.js +135 -66
- package/plugins/admin-panel/field-renderers/index.js +1 -0
- package/plugins/admin-panel/index.js +8 -0
- package/plugins/index.js +3 -0
- package/plugins/upload/index.js +188 -0
- package/plugins/upload/local-file-provider.js +122 -0
- package/src/client-runtime/bootstrap-alpine-swup.js +34 -0
- package/src/client-runtime/bootstrap-swup.js +26 -0
- package/src/client-runtime/mount.js +65 -0
- package/src/client-runtime/resolve.js +40 -0
- package/src/file-router.js +16 -2
- package/src/server.js +11 -2
- package/templates/skills/webspresso-usage/SKILL.md +54 -21
- package/views/partials/webspresso-client-runtime.njk +15 -0
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ A minimal, file-based SSR framework for Node.js with Nunjucks templating.
|
|
|
17
17
|
- **Plugin System**: Extensible architecture with version control and inter-plugin communication
|
|
18
18
|
- **Built-in Plugins**: Development dashboard, sitemap generator, SEO checker, analytics integration (Google, Yandex, Bing), self-hosted site analytics, optional Swagger UI for HTTP APIs, configurable HTTP health probe endpoint, optional REST CRUD routes from ORM models, optional admin UI for ORM query cache metrics and purge
|
|
19
19
|
- **Session authentication** (optional): `createAuth` / `quickAuth` in **`webspresso/core/auth`** — pass the manager to **`createApp({ auth })`** for `express-session`, `req.user` / `req.auth`, remember-me tokens, and policy-style authorization. Full walkthrough: **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
20
|
+
- **Optional client runtime** (Alpine.js + [swup](https://swup.js.org/)): **`createApp({ clientRuntime: { alpine, swup } })`** serves scripts under **`/__webspresso/client-runtime/`** and exposes **`clientRuntime`** in Nunjucks; layouts can include **`views/partials/webspresso-client-runtime.njk`**. Env overrides: **`WEBSPRESSO_ALPINE`**, **`WEBSPRESSO_SWUP`**. Demo: **`examples/alpine-swup-demo/`**. Details: **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
|
|
20
21
|
- **TypeScript**: Published **`index.d.ts`** (via `package.json` `"types"`) for `createApp`, ORM, plugins, and router helpers — use from TS/JS with IDE autocomplete; runtime stays CommonJS
|
|
21
22
|
|
|
22
23
|
## Installation
|
|
@@ -128,6 +129,17 @@ If you select a database:
|
|
|
128
129
|
- `models/` directory is created
|
|
129
130
|
- `DATABASE_URL` is added to `.env.example` with a template
|
|
130
131
|
|
|
132
|
+
**Scaffold: `config/` and environment files**
|
|
133
|
+
|
|
134
|
+
New projects include:
|
|
135
|
+
|
|
136
|
+
- **`config/load-env.js`** — loads, in order, `.env`, `.env.local`, `.env.${NODE_ENV}`, and `.env.${NODE_ENV}.local` (each file overrides keys from earlier ones).
|
|
137
|
+
- **`config/env.schema.js`** — validates `process.env` with **Zod** before the app starts (`PORT`, `NODE_ENV`, i18n vars, `BASE_URL`, optional `DATABASE_URL`).
|
|
138
|
+
- **`config/app.js`** — returns `createApp()` options (paths; if `webspresso.db.js` exists, also **`createDatabase`** as `db`).
|
|
139
|
+
- **`server.js`** — calls `loadEnv()`, then `createApp(getCreateAppOptions())`, then `listen` using the parsed `PORT`.
|
|
140
|
+
|
|
141
|
+
Copy **`.env.example`** to **`.env`** (and use `.env.local` for machine-specific overrides). Patterns such as `.env.development.local` are gitignored via `.env.*.local`.
|
|
142
|
+
|
|
131
143
|
**Seed Data Generation:**
|
|
132
144
|
After selecting a database, you'll be asked if you want to generate seed data:
|
|
133
145
|
- If yes, `@faker-js/faker` is added to dependencies
|
|
@@ -357,8 +369,9 @@ Creates and configures the Express app.
|
|
|
357
369
|
- `false`: Disable Helmet
|
|
358
370
|
- `Object`: Custom Helmet configuration (merged with defaults)
|
|
359
371
|
- `middlewares` (optional): Named middleware registry for routes
|
|
372
|
+
- `clientRuntime` (optional): **`{ alpine?: boolean | object, swup?: boolean | object }`**. When either flag is enabled, mounts vendored Alpine 3 / swup 4 (+ Head + Scripts plugins) at **`/__webspresso/client-runtime/*`** and passes resolved **`{ alpine, swup }`** into SSR templates as **`clientRuntime`**. Override with env **`WEBSPRESSO_ALPINE`** / **`WEBSPRESSO_SWUP`** (`1` or `true`). Package exports **`resolveClientRuntime`** and **`CLIENT_RUNTIME_BASE`**. See **[Client runtime](#client-runtime)** below and **[`doc/index.html#client-runtime`](doc/index.html#client-runtime)**.
|
|
360
373
|
- `auth` (optional): `AuthManager` from **`createAuth()`** / **`quickAuth()`** (`require('webspresso/core/auth')`). Registers session + cookie parsing, attaches **`req.auth`** / **`req.user`**, and injects named route middleware **`auth`** and **`guest`** (do not reuse those names for custom handlers if you pass `auth`). See **[`doc/index.html#authentication`](doc/index.html#authentication)**.
|
|
361
|
-
- `setupRoutes(app, ctx)` (optional): Register custom Express routes after file routes and plugin `onRoutesReady`, before the 404 handler — use for login/logout handlers when using the auth module; **`ctx.authMiddleware`** exposes `requireAuth`, `requireGuest`, etc.
|
|
374
|
+
- `setupRoutes(app, ctx)` (optional): Register custom Express routes after file routes and plugin `onRoutesReady`, before the 404 handler — use for login/logout handlers when using the auth module; **`ctx.authMiddleware`** exposes `requireAuth`, `requireGuest`, etc.; **`ctx.clientRuntime`** is **`{ alpine, swup }`**
|
|
362
375
|
|
|
363
376
|
**Example with middlewares:**
|
|
364
377
|
|
|
@@ -423,6 +436,27 @@ middlewares: {
|
|
|
423
436
|
|
|
424
437
|
Plain `(req, res, next) => …` handlers still work as today. Tuple form **requires** a factory for that name (you get a clear error if you mix a plain handler with `['name', opts]`).
|
|
425
438
|
|
|
439
|
+
### Client runtime
|
|
440
|
+
|
|
441
|
+
Alpine.js + swup — opt-in progressive enhancement for SSR pages:
|
|
442
|
+
|
|
443
|
+
- **`clientRuntime: { alpine: true, swup: true }`** (each can be toggled independently). Default is off; no scripts are injected when both are disabled.
|
|
444
|
+
- Include **`{% include "partials/webspresso-client-runtime.njk" %}`** in your layout (copy from the npm package’s **`views/partials/`** or the framework repo). When **swup** is on, wrap the main content in **`<main id="swup">…</main>`** so transitions replace the correct region.
|
|
445
|
+
- **swup** uses Head + Scripts plugins; **Alpine** is re-bound after each visit via **`Alpine.initTree`** on the container. Use **`data-no-swup`** on links for a full page load. Paths **`/_admin`** and **`/_webspresso`** are ignored by the default bootstrap; the admin panel and dev dashboard stay separate Mithril apps.
|
|
446
|
+
- Dynamic UI can call **`pages/api/*`** from Alpine with **`fetch`** (see **`examples/alpine-swup-demo/`**).
|
|
447
|
+
- **Helmet / CSP**: production **`script-src 'self'`** works for **`/__webspresso/client-runtime/`**; some Alpine builds may need **`unsafe-eval`** — validate for your version or use a stricter build.
|
|
448
|
+
|
|
449
|
+
```javascript
|
|
450
|
+
const { createApp } = require('webspresso');
|
|
451
|
+
|
|
452
|
+
const { app } = createApp({
|
|
453
|
+
pagesDir: './pages',
|
|
454
|
+
viewsDir: './views',
|
|
455
|
+
publicDir: './public',
|
|
456
|
+
clientRuntime: { alpine: true, swup: true },
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
426
460
|
### App context (`req.db`, `getDb`, `attachDbMiddleware`)
|
|
427
461
|
|
|
428
462
|
With **`createApp({ db })`**, file-based **API** routes (`pages/api/*.js`) get the same ORM instance on **`req.db`** before your **`middleware`** array and the handler run — no extra `require` in the handler:
|
|
@@ -2018,6 +2052,45 @@ const { app } = createApp({
|
|
|
2018
2052
|
|
|
2019
2053
|
List query parameters: `page`, `perPage`, `sort`, `order`, `include`, `trashed` (`only` / `include` when the model uses soft delete), plus **equality filters** on real columns (unknown keys are ignored).
|
|
2020
2054
|
|
|
2055
|
+
### File upload plugin
|
|
2056
|
+
|
|
2057
|
+
Registers **POST** `multipart/form-data` (field name **`file`** by default) and stores the file via a pluggable provider. The framework ships with **`createLocalFileProvider({ destDir, publicBasePath })`** (writes to disk and returns a public URL path). Use **`mimeAllowlist`** / **`extensionAllowlist`** and **`maxBytes`** (default **10 MiB**) on the server; production apps should prefer an explicit MIME allowlist.
|
|
2058
|
+
|
|
2059
|
+
**Setup:**
|
|
2060
|
+
|
|
2061
|
+
```javascript
|
|
2062
|
+
const { createApp, uploadPlugin, adminPanelPlugin } = require('webspresso');
|
|
2063
|
+
|
|
2064
|
+
const { app } = createApp({
|
|
2065
|
+
pagesDir: './pages',
|
|
2066
|
+
publicDir: './public',
|
|
2067
|
+
db,
|
|
2068
|
+
plugins: [
|
|
2069
|
+
uploadPlugin({
|
|
2070
|
+
path: '/api/upload',
|
|
2071
|
+
local: {
|
|
2072
|
+
destDir: './public/uploads',
|
|
2073
|
+
publicBasePath: '/uploads',
|
|
2074
|
+
},
|
|
2075
|
+
maxBytes: 10 * 1024 * 1024,
|
|
2076
|
+
mimeAllowlist: ['image/jpeg', 'image/png', 'application/pdf'],
|
|
2077
|
+
extensionAllowlist: ['jpg', 'jpeg', 'png', 'pdf'],
|
|
2078
|
+
middleware: [], // optional Express handlers (e.g. session auth)
|
|
2079
|
+
fieldName: 'file',
|
|
2080
|
+
}),
|
|
2081
|
+
adminPanelPlugin({
|
|
2082
|
+
db,
|
|
2083
|
+
// uploadUrl optional: defaults to app.get('webspresso.uploadPath') set by uploadPlugin
|
|
2084
|
+
}),
|
|
2085
|
+
],
|
|
2086
|
+
});
|
|
2087
|
+
```
|
|
2088
|
+
|
|
2089
|
+
- **ORM:** `zdb.file({ maxLength: 2048, nullable: true })` — string column for the stored public URL or path; migrations use `table.string(..., maxLength)`.
|
|
2090
|
+
- **Admin:** the panel reads **`settings.uploadUrl`** from the registry (set automatically when `uploadPlugin` is registered **before** `adminPanelPlugin`, or pass **`adminPanelPlugin({ uploadUrl: '/api/upload' })`**). File fields (`type: 'file'` or `customFields` type `file-upload`) POST to that URL with credentials.
|
|
2091
|
+
- **Response:** `{ url, publicUrl, key? }` — clients typically persist **`url`** / **`publicUrl`** in the model.
|
|
2092
|
+
- **Custom storage:** `uploadPlugin({ provider: { async put({ buffer, originalName, mimeType, size, req }) { return { publicUrl: '...' }; } } })`.
|
|
2093
|
+
|
|
2021
2094
|
### Health check plugin
|
|
2022
2095
|
|
|
2023
2096
|
Exposes a lightweight **GET** endpoint for load balancers and orchestrators (Kubernetes, Docker healthcheck, etc.). **Enabled by default** in all environments; set `enabled: false` to turn it off.
|
package/bin/commands/doctor.js
CHANGED
|
@@ -107,6 +107,29 @@ function registerCommand(program) {
|
|
|
107
107
|
warnings += 1;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
console.log('\nEnvironment files');
|
|
111
|
+
console.log('-----------------');
|
|
112
|
+
|
|
113
|
+
const envExamplePath = path.join(cwd, '.env.example');
|
|
114
|
+
const envPath = path.join(cwd, '.env');
|
|
115
|
+
if (fs.existsSync(envExamplePath)) {
|
|
116
|
+
line('✓', '.env.example present');
|
|
117
|
+
} else {
|
|
118
|
+
line('○', 'No .env.example (optional; new scaffolds include one)');
|
|
119
|
+
}
|
|
120
|
+
if (fs.existsSync(envPath)) {
|
|
121
|
+
line('✓', '.env present');
|
|
122
|
+
} else if (fs.existsSync(envExamplePath)) {
|
|
123
|
+
line('⚠', 'No .env — copy .env.example to .env if the app expects env vars');
|
|
124
|
+
warnings += 1;
|
|
125
|
+
} else {
|
|
126
|
+
line('○', 'No .env (fine if you do not use dotenv in this project)');
|
|
127
|
+
}
|
|
128
|
+
const loadEnvPath = path.join(cwd, 'config', 'load-env.js');
|
|
129
|
+
if (fs.existsSync(loadEnvPath)) {
|
|
130
|
+
line('✓', 'config/load-env.js present (dotenv chain scaffold)');
|
|
131
|
+
}
|
|
132
|
+
|
|
110
133
|
console.log('\nDatabase config');
|
|
111
134
|
console.log('---------------');
|
|
112
135
|
|
package/bin/commands/new.js
CHANGED
|
@@ -159,6 +159,7 @@ function registerCommand(program) {
|
|
|
159
159
|
fs.mkdirSync(path.join(projectPath, 'pages', 'locales'), { recursive: true });
|
|
160
160
|
fs.mkdirSync(path.join(projectPath, 'views'), { recursive: true });
|
|
161
161
|
fs.mkdirSync(path.join(projectPath, 'public'), { recursive: true });
|
|
162
|
+
fs.mkdirSync(path.join(projectPath, 'config'), { recursive: true });
|
|
162
163
|
|
|
163
164
|
// Create models directory if database is selected
|
|
164
165
|
if (useDatabase && databaseType) {
|
|
@@ -177,7 +178,8 @@ function registerCommand(program) {
|
|
|
177
178
|
},
|
|
178
179
|
dependencies: {
|
|
179
180
|
webspresso: '*',
|
|
180
|
-
dotenv: '^16.3.1'
|
|
181
|
+
dotenv: '^16.3.1',
|
|
182
|
+
zod: '^3.23.0'
|
|
181
183
|
}
|
|
182
184
|
};
|
|
183
185
|
|
|
@@ -203,28 +205,108 @@ function registerCommand(program) {
|
|
|
203
205
|
JSON.stringify(packageJson, null, 2) + '\n'
|
|
204
206
|
);
|
|
205
207
|
|
|
206
|
-
//
|
|
207
|
-
const
|
|
208
|
-
const { createApp } = require('webspresso');
|
|
208
|
+
// config/load-env.js — dotenv chain (last file wins for each key)
|
|
209
|
+
const loadEnvJs = `const fs = require('fs');
|
|
209
210
|
const path = require('path');
|
|
211
|
+
const dotenv = require('dotenv');
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Load env files in order: .env, .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
|
|
215
|
+
* @param {string} [rootDir] Project root (default: parent of config/)
|
|
216
|
+
*/
|
|
217
|
+
function loadEnv(rootDir) {
|
|
218
|
+
const root = rootDir || path.resolve(__dirname, '..');
|
|
219
|
+
const loadFile = (name) => {
|
|
220
|
+
const full = path.join(root, name);
|
|
221
|
+
if (fs.existsSync(full)) {
|
|
222
|
+
dotenv.config({ path: full, override: true });
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
loadFile('.env');
|
|
226
|
+
loadFile('.env.local');
|
|
227
|
+
const mode = process.env.NODE_ENV || 'development';
|
|
228
|
+
loadFile(\`.env.\${mode}\`);
|
|
229
|
+
loadFile(\`.env.\${mode}.local\`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = { loadEnv };
|
|
233
|
+
`;
|
|
210
234
|
|
|
211
|
-
const {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
235
|
+
const envSchemaJs = `const { z } = require('zod');
|
|
236
|
+
|
|
237
|
+
const envSchema = z.object({
|
|
238
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
239
|
+
PORT: z.coerce.number().int().positive().default(3000),
|
|
240
|
+
DEFAULT_LOCALE: z.string().min(1).default('en'),
|
|
241
|
+
SUPPORTED_LOCALES: z.string().min(1).default('en,de'),
|
|
242
|
+
BASE_URL: z.string().url().default('http://localhost:3000'),
|
|
243
|
+
DATABASE_URL: z.string().optional(),
|
|
215
244
|
});
|
|
216
245
|
|
|
217
|
-
|
|
246
|
+
let _parsed = null;
|
|
247
|
+
|
|
248
|
+
function parseEnv() {
|
|
249
|
+
if (_parsed) return _parsed;
|
|
250
|
+
const result = envSchema.safeParse(process.env);
|
|
251
|
+
if (!result.success) {
|
|
252
|
+
console.error('Invalid environment variables:');
|
|
253
|
+
console.error(result.error.format());
|
|
254
|
+
process.exit(1);
|
|
255
|
+
}
|
|
256
|
+
_parsed = result.data;
|
|
257
|
+
return _parsed;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
module.exports = { envSchema, parseEnv };
|
|
261
|
+
`;
|
|
262
|
+
|
|
263
|
+
const appConfigJs = `const path = require('path');
|
|
264
|
+
const fs = require('fs');
|
|
265
|
+
const { parseEnv } = require('./env.schema');
|
|
266
|
+
|
|
267
|
+
function getCreateAppOptions() {
|
|
268
|
+
parseEnv();
|
|
269
|
+
const rootDir = path.resolve(__dirname, '..');
|
|
270
|
+
const options = {
|
|
271
|
+
pagesDir: path.join(rootDir, 'pages'),
|
|
272
|
+
viewsDir: path.join(rootDir, 'views'),
|
|
273
|
+
publicDir: path.join(rootDir, 'public'),
|
|
274
|
+
};
|
|
275
|
+
const dbFile = path.join(rootDir, 'webspresso.db.js');
|
|
276
|
+
if (fs.existsSync(dbFile)) {
|
|
277
|
+
const { createDatabase } = require('webspresso');
|
|
278
|
+
const knexConfig = require(dbFile);
|
|
279
|
+
options.db = createDatabase(knexConfig);
|
|
280
|
+
}
|
|
281
|
+
return options;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = getCreateAppOptions;
|
|
285
|
+
`;
|
|
286
|
+
|
|
287
|
+
const serverJs = `const { loadEnv } = require('./config/load-env');
|
|
288
|
+
loadEnv();
|
|
218
289
|
|
|
219
|
-
|
|
220
|
-
|
|
290
|
+
const { createApp } = require('webspresso');
|
|
291
|
+
const getCreateAppOptions = require('./config/app');
|
|
292
|
+
const { parseEnv } = require('./config/env.schema');
|
|
293
|
+
|
|
294
|
+
const env = parseEnv();
|
|
295
|
+
const { app } = createApp(getCreateAppOptions());
|
|
296
|
+
|
|
297
|
+
app.listen(env.PORT, () => {
|
|
298
|
+
console.log(\`🚀 Server running at http://localhost:\${env.PORT}\`);
|
|
221
299
|
});
|
|
222
300
|
`;
|
|
223
|
-
|
|
301
|
+
|
|
302
|
+
fs.writeFileSync(path.join(projectPath, 'config', 'load-env.js'), loadEnvJs);
|
|
303
|
+
fs.writeFileSync(path.join(projectPath, 'config', 'env.schema.js'), envSchemaJs);
|
|
304
|
+
fs.writeFileSync(path.join(projectPath, 'config', 'app.js'), appConfigJs);
|
|
224
305
|
fs.writeFileSync(path.join(projectPath, 'server.js'), serverJs);
|
|
225
306
|
|
|
226
|
-
// Create .env.example
|
|
227
|
-
let envExample =
|
|
307
|
+
// Create .env.example (see config/load-env.js for merge order)
|
|
308
|
+
let envExample = `# Copy to .env and adjust. Optional overrides: .env.local, .env.<NODE_ENV>, .env.<NODE_ENV>.local
|
|
309
|
+
PORT=3000
|
|
228
310
|
NODE_ENV=development
|
|
229
311
|
DEFAULT_LOCALE=en
|
|
230
312
|
SUPPORTED_LOCALES=en,de
|
|
@@ -308,10 +390,11 @@ BASE_URL=http://localhost:3000
|
|
|
308
390
|
fs.writeFileSync(path.join(projectPath, 'seeds', 'index.js'), seedIndex);
|
|
309
391
|
}
|
|
310
392
|
|
|
311
|
-
// Create .gitignore
|
|
393
|
+
// Create .gitignore (.env optional: teams often commit a non-secret .env for local defaults)
|
|
312
394
|
const gitignore = `node_modules/
|
|
313
395
|
.env
|
|
314
396
|
.env.local
|
|
397
|
+
.env.*.local
|
|
315
398
|
.DS_Store
|
|
316
399
|
coverage/
|
|
317
400
|
*.log
|
|
@@ -482,10 +565,18 @@ Webspresso project
|
|
|
482
565
|
|
|
483
566
|
\`\`\`bash
|
|
484
567
|
npm install
|
|
568
|
+
cp .env.example .env
|
|
485
569
|
npm run dev
|
|
486
570
|
\`\`\`
|
|
487
571
|
|
|
488
572
|
Visit http://localhost:3000
|
|
573
|
+
|
|
574
|
+
## Configuration
|
|
575
|
+
|
|
576
|
+
- **\`config/load-env.js\`** — loads \`.env\`, \`.env.local\`, then \`.env.$NODE_ENV\` and \`.env.$NODE_ENV.local\` (later files override keys).
|
|
577
|
+
- **\`config/env.schema.js\`** — [Zod](https://zod.dev) schema for \`process.env\`; fails fast on invalid values.
|
|
578
|
+
- **\`config/app.js\`** — builds options passed to \`createApp()\` (paths; adds \`db\` when \`webspresso.db.js\` exists).
|
|
579
|
+
- **\`server.js\`** — calls \`loadEnv()\`, then \`createApp(getCreateAppOptions())\`.
|
|
489
580
|
`;
|
|
490
581
|
|
|
491
582
|
fs.writeFileSync(path.join(projectPath, 'README.md'), readme);
|
|
@@ -112,6 +112,12 @@ function generateColumnLine(columnName, meta) {
|
|
|
112
112
|
parts.push(`table.text('${columnName}')`);
|
|
113
113
|
break;
|
|
114
114
|
|
|
115
|
+
case 'file': {
|
|
116
|
+
const fileLen = meta.maxLength || 2048;
|
|
117
|
+
parts.push(`table.string('${columnName}', ${fileLen})`);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
|
|
115
121
|
case 'float':
|
|
116
122
|
parts.push(`table.float('${columnName}')`);
|
|
117
123
|
break;
|
|
@@ -420,6 +420,25 @@ function createSchemaHelpers(z) {
|
|
|
420
420
|
}, z);
|
|
421
421
|
},
|
|
422
422
|
|
|
423
|
+
/**
|
|
424
|
+
* File / URL column (stored as varchar; value is public URL or path from upload)
|
|
425
|
+
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
|
426
|
+
* @returns {SchemaBuilder}
|
|
427
|
+
*/
|
|
428
|
+
file(options = {}) {
|
|
429
|
+
const { maxLength = 2048, nullable = false, ...rest } = options;
|
|
430
|
+
let schema = z.string().max(maxLength);
|
|
431
|
+
if (nullable) {
|
|
432
|
+
schema = schema.nullable().optional();
|
|
433
|
+
}
|
|
434
|
+
return createSchemaBuilder(schema, {
|
|
435
|
+
type: 'file',
|
|
436
|
+
maxLength,
|
|
437
|
+
nullable,
|
|
438
|
+
...rest,
|
|
439
|
+
}, z);
|
|
440
|
+
},
|
|
441
|
+
|
|
423
442
|
/**
|
|
424
443
|
* Integer column
|
|
425
444
|
* @param {Partial<import('./types').ColumnMeta>} [options={}]
|
package/core/orm/types.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// ============================================================================
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* @typedef {'id'|'string'|'text'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
|
|
12
|
+
* @typedef {'id'|'string'|'text'|'file'|'integer'|'bigint'|'float'|'decimal'|'boolean'|'date'|'datetime'|'timestamp'|'json'|'array'|'enum'|'uuid'|'nanoid'} ColumnType
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
/**
|
package/index.d.ts
CHANGED
|
@@ -45,6 +45,11 @@ export interface CreateAppOptions {
|
|
|
45
45
|
timeout?: string | false;
|
|
46
46
|
auth?: unknown;
|
|
47
47
|
db?: DatabaseInstance | null;
|
|
48
|
+
/** Opt-in Alpine / swup assets under `/__webspresso/client-runtime/*`. Env: WEBSPRESSO_ALPINE, WEBSPRESSO_SWUP. */
|
|
49
|
+
clientRuntime?: {
|
|
50
|
+
alpine?: boolean | Record<string, unknown>;
|
|
51
|
+
swup?: boolean | Record<string, unknown>;
|
|
52
|
+
};
|
|
48
53
|
setupRoutes?: (app: Application, ctx: SetupRoutesContext) => void;
|
|
49
54
|
[key: string]: unknown;
|
|
50
55
|
}
|
|
@@ -54,6 +59,7 @@ export interface SetupRoutesContext {
|
|
|
54
59
|
authMiddleware?: RequestHandler;
|
|
55
60
|
pluginManager: PluginManager;
|
|
56
61
|
options: CreateAppOptions;
|
|
62
|
+
clientRuntime: { alpine: boolean; swup: boolean };
|
|
57
63
|
}
|
|
58
64
|
|
|
59
65
|
export interface CreateAppResult {
|
|
@@ -525,3 +531,33 @@ export interface RestResourcePluginOptions {
|
|
|
525
531
|
export function restResourcePlugin(options?: RestResourcePluginOptions): WebspressoPlugin;
|
|
526
532
|
|
|
527
533
|
export function ormCacheAdminPlugin(options: { db: DatabaseInstance }): WebspressoPlugin;
|
|
534
|
+
|
|
535
|
+
/** Multipart upload storage (e.g. local disk or S3). */
|
|
536
|
+
export interface UploadStorageProvider {
|
|
537
|
+
put(args: {
|
|
538
|
+
buffer?: Buffer;
|
|
539
|
+
stream?: NodeJS.ReadableStream;
|
|
540
|
+
originalName: string;
|
|
541
|
+
mimeType: string;
|
|
542
|
+
size: number;
|
|
543
|
+
req: Request;
|
|
544
|
+
}): Promise<{ publicUrl: string; key?: string }>;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export interface UploadPluginOptions {
|
|
548
|
+
path?: string;
|
|
549
|
+
provider?: UploadStorageProvider;
|
|
550
|
+
local?: { destDir?: string; publicBasePath?: string };
|
|
551
|
+
maxBytes?: number;
|
|
552
|
+
mimeAllowlist?: string[] | null;
|
|
553
|
+
extensionAllowlist?: string[] | null;
|
|
554
|
+
middleware?: RequestHandler | RequestHandler[];
|
|
555
|
+
fieldName?: string;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export function uploadPlugin(options?: UploadPluginOptions): WebspressoPlugin;
|
|
559
|
+
|
|
560
|
+
export function createLocalFileProvider(options?: {
|
|
561
|
+
destDir?: string;
|
|
562
|
+
publicBasePath?: string;
|
|
563
|
+
}): UploadStorageProvider;
|
package/index.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
const { createApp } = require('./src/server');
|
|
6
|
+
const { resolveClientRuntime } = require('./src/client-runtime/resolve');
|
|
7
|
+
const { CLIENT_RUNTIME_BASE } = require('./src/client-runtime/mount');
|
|
6
8
|
const {
|
|
7
9
|
attachDbMiddleware,
|
|
8
10
|
getAppContext,
|
|
@@ -38,11 +40,25 @@ const {
|
|
|
38
40
|
const orm = require('./core/orm');
|
|
39
41
|
|
|
40
42
|
// Built-in plugins
|
|
41
|
-
const {
|
|
43
|
+
const {
|
|
44
|
+
schemaExplorerPlugin,
|
|
45
|
+
adminPanelPlugin,
|
|
46
|
+
siteAnalyticsPlugin,
|
|
47
|
+
auditLogPlugin,
|
|
48
|
+
recaptchaPlugin,
|
|
49
|
+
swaggerPlugin,
|
|
50
|
+
healthCheckPlugin,
|
|
51
|
+
restResourcePlugin,
|
|
52
|
+
ormCacheAdminPlugin,
|
|
53
|
+
uploadPlugin,
|
|
54
|
+
createLocalFileProvider,
|
|
55
|
+
} = require('./plugins');
|
|
42
56
|
|
|
43
57
|
module.exports = {
|
|
44
58
|
// Main API
|
|
45
59
|
createApp,
|
|
60
|
+
resolveClientRuntime,
|
|
61
|
+
CLIENT_RUNTIME_BASE,
|
|
46
62
|
|
|
47
63
|
attachDbMiddleware,
|
|
48
64
|
getAppContext,
|
|
@@ -91,4 +107,6 @@ module.exports = {
|
|
|
91
107
|
healthCheckPlugin,
|
|
92
108
|
restResourcePlugin,
|
|
93
109
|
ormCacheAdminPlugin,
|
|
110
|
+
uploadPlugin,
|
|
111
|
+
createLocalFileProvider,
|
|
94
112
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "webspresso",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.68",
|
|
4
4
|
"description": "Minimal, production-ready SSR framework for Node.js with file-based routing, Nunjucks templating, built-in i18n, and CLI tooling",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"types": "index.d.ts",
|
|
@@ -42,9 +42,13 @@
|
|
|
42
42
|
"utils/",
|
|
43
43
|
"core/",
|
|
44
44
|
"plugins/",
|
|
45
|
-
"templates/"
|
|
45
|
+
"templates/",
|
|
46
|
+
"views/partials/webspresso-client-runtime.njk"
|
|
46
47
|
],
|
|
47
48
|
"dependencies": {
|
|
49
|
+
"@swup/head-plugin": "^2.3.1",
|
|
50
|
+
"@swup/scripts-plugin": "^2.1.0",
|
|
51
|
+
"alpinejs": "^3.15.11",
|
|
48
52
|
"bcrypt": "^5.1.1",
|
|
49
53
|
"commander": "^11.1.0",
|
|
50
54
|
"connect-timeout": "^1.9.1",
|
|
@@ -55,8 +59,10 @@
|
|
|
55
59
|
"helmet": "^7.2.0",
|
|
56
60
|
"inquirer": "^8.2.6",
|
|
57
61
|
"knex": "^3.1.0",
|
|
62
|
+
"multer": "^2.1.1",
|
|
58
63
|
"nunjucks": "^3.2.4",
|
|
59
64
|
"sharp": "^0.33.5",
|
|
65
|
+
"swup": "^4.8.3",
|
|
60
66
|
"zod": "^3.23.0",
|
|
61
67
|
"zod-to-json-schema": "^3.25.2"
|
|
62
68
|
},
|
|
@@ -85,10 +91,10 @@
|
|
|
85
91
|
}
|
|
86
92
|
},
|
|
87
93
|
"devDependencies": {
|
|
88
|
-
"@types/express": "^4.17.21",
|
|
89
|
-
"@types/node": "^20.14.0",
|
|
90
94
|
"@faker-js/faker": "^9.9.0",
|
|
91
95
|
"@playwright/test": "^1.48.0",
|
|
96
|
+
"@types/express": "^4.17.21",
|
|
97
|
+
"@types/node": "^20.14.0",
|
|
92
98
|
"@vitest/coverage-v8": "^3.0.0",
|
|
93
99
|
"better-sqlite3": "^11.10.0",
|
|
94
100
|
"chokidar": "^3.5.3",
|
|
@@ -102,6 +108,7 @@
|
|
|
102
108
|
"node": ">=18.0.0"
|
|
103
109
|
},
|
|
104
110
|
"overrides": {
|
|
111
|
+
"basic-ftp": "^5.3.0",
|
|
105
112
|
"tar": "^7.5.11",
|
|
106
113
|
"undici": "^6.24.0"
|
|
107
114
|
}
|
|
@@ -308,8 +308,8 @@ function createApiHandlers(options) {
|
|
|
308
308
|
} else if (value !== undefined && value !== null && value !== '') {
|
|
309
309
|
switch (op) {
|
|
310
310
|
case 'contains':
|
|
311
|
-
// Apply LIKE for string/text types, or if type is unknown
|
|
312
|
-
if (colType === 'string' || colType === 'text' || !colMeta) {
|
|
311
|
+
// Apply LIKE for string/text/file types, or if type is unknown
|
|
312
|
+
if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
|
|
313
313
|
query = query.where(colName, 'like', `%${value}%`);
|
|
314
314
|
countQuery = countQuery.where(colName, 'like', `%${value}%`);
|
|
315
315
|
}
|
|
@@ -319,13 +319,13 @@ function createApiHandlers(options) {
|
|
|
319
319
|
countQuery = countQuery.where(colName, '=', value);
|
|
320
320
|
break;
|
|
321
321
|
case 'starts_with':
|
|
322
|
-
if (colType === 'string' || colType === 'text' || !colMeta) {
|
|
322
|
+
if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
|
|
323
323
|
query = query.where(colName, 'like', `${value}%`);
|
|
324
324
|
countQuery = countQuery.where(colName, 'like', `${value}%`);
|
|
325
325
|
}
|
|
326
326
|
break;
|
|
327
327
|
case 'ends_with':
|
|
328
|
-
if (colType === 'string' || colType === 'text' || !colMeta) {
|
|
328
|
+
if (colType === 'string' || colType === 'text' || colType === 'file' || !colMeta) {
|
|
329
329
|
query = query.where(colName, 'like', `%${value}`);
|
|
330
330
|
countQuery = countQuery.where(colName, 'like', `%${value}`);
|
|
331
331
|
}
|
|
@@ -359,7 +359,7 @@ function createApiHandlers(options) {
|
|
|
359
359
|
if (req.query.search) {
|
|
360
360
|
const searchTerm = `%${req.query.search}%`;
|
|
361
361
|
const stringColumns = Array.from(model.columns.entries())
|
|
362
|
-
.filter(([_, meta]) => meta.type === 'string' || meta.type === 'text')
|
|
362
|
+
.filter(([_, meta]) => meta.type === 'string' || meta.type === 'text' || meta.type === 'file')
|
|
363
363
|
.map(([name]) => name);
|
|
364
364
|
|
|
365
365
|
if (stringColumns.length > 0) {
|