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
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File upload plugin — multipart parsing, validation, pluggable storage.
|
|
3
|
+
* @module plugins/upload
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const multer = require('multer');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { createLocalFileProvider } = require('./local-file-provider');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {import('express').Request} ExpressRequest
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @typedef {Object} UploadPutArgs
|
|
16
|
+
* @property {Buffer} [buffer]
|
|
17
|
+
* @property {import('stream').Readable} [stream]
|
|
18
|
+
* @property {string} originalName
|
|
19
|
+
* @property {string} mimeType
|
|
20
|
+
* @property {number} size
|
|
21
|
+
* @property {ExpressRequest} req
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {Object} UploadPutResult
|
|
26
|
+
* @property {string} publicUrl
|
|
27
|
+
* @property {string} [key]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {Object} UploadStorageProvider
|
|
32
|
+
* @property {(args: UploadPutArgs) => Promise<UploadPutResult>} put
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} [p]
|
|
37
|
+
* @returns {string}
|
|
38
|
+
*/
|
|
39
|
+
function normalizeRoutePath(p) {
|
|
40
|
+
const s = (p || '/api/upload').trim();
|
|
41
|
+
return s.startsWith('/') ? s : `/${s}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} [name]
|
|
46
|
+
* @returns {string} lowercase extension without dot
|
|
47
|
+
*/
|
|
48
|
+
function extensionFromName(name) {
|
|
49
|
+
return path.extname(path.basename(name || '')).replace(/^\./, '').toLowerCase();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {number} maxBytes
|
|
54
|
+
* @param {string} fieldName
|
|
55
|
+
* @returns {import('express').RequestHandler}
|
|
56
|
+
*/
|
|
57
|
+
function createMulterSingleMiddleware(maxBytes, fieldName) {
|
|
58
|
+
const upload = multer({
|
|
59
|
+
storage: multer.memoryStorage(),
|
|
60
|
+
limits: { fileSize: maxBytes, files: 1 },
|
|
61
|
+
});
|
|
62
|
+
return upload.single(fieldName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {unknown} err
|
|
67
|
+
* @returns {{ status: number, message: string }}
|
|
68
|
+
*/
|
|
69
|
+
function multerErrorResponse(err) {
|
|
70
|
+
if (err instanceof multer.MulterError) {
|
|
71
|
+
if (err.code === 'LIMIT_FILE_SIZE') {
|
|
72
|
+
return { status: 413, message: 'File too large' };
|
|
73
|
+
}
|
|
74
|
+
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
|
|
75
|
+
return { status: 400, message: 'Invalid upload (too many files or unexpected field)' };
|
|
76
|
+
}
|
|
77
|
+
return { status: 400, message: err.message || 'Upload error' };
|
|
78
|
+
}
|
|
79
|
+
return { status: 400, message: err instanceof Error ? err.message : String(err) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {Object} [options]
|
|
84
|
+
* @param {string} [options.path='/api/upload'] — POST route
|
|
85
|
+
* @param {UploadStorageProvider} [options.provider] — Storage backend; default: local disk
|
|
86
|
+
* @param {Object} [options.local] — Shorthand for createLocalFileProvider when provider omitted
|
|
87
|
+
* @param {string} [options.local.destDir]
|
|
88
|
+
* @param {string} [options.local.publicBasePath]
|
|
89
|
+
* @param {number} [options.maxBytes=10485760] — 10 MiB default
|
|
90
|
+
* @param {string[]|null} [options.mimeAllowlist] — If set, reject other MIME types (415)
|
|
91
|
+
* @param {string[]|null} [options.extensionAllowlist] — Optional extra guard on original extension
|
|
92
|
+
* @param {import('express').RequestHandler|import('express').RequestHandler[]} [options.middleware]
|
|
93
|
+
* @param {string} [options.fieldName='file'] — Multipart field name
|
|
94
|
+
* @returns {Object} Webspresso plugin
|
|
95
|
+
*/
|
|
96
|
+
function uploadPlugin(options = {}) {
|
|
97
|
+
const {
|
|
98
|
+
path: routePath = '/api/upload',
|
|
99
|
+
provider: userProvider,
|
|
100
|
+
local,
|
|
101
|
+
maxBytes = 10 * 1024 * 1024,
|
|
102
|
+
mimeAllowlist = null,
|
|
103
|
+
extensionAllowlist = null,
|
|
104
|
+
middleware = [],
|
|
105
|
+
fieldName = 'file',
|
|
106
|
+
} = options;
|
|
107
|
+
|
|
108
|
+
const normalizedPath = normalizeRoutePath(routePath);
|
|
109
|
+
|
|
110
|
+
const provider =
|
|
111
|
+
userProvider ||
|
|
112
|
+
createLocalFileProvider({
|
|
113
|
+
destDir: local?.destDir,
|
|
114
|
+
publicBasePath: local?.publicBasePath,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const mw = (Array.isArray(middleware) ? middleware : [middleware]).filter(Boolean);
|
|
118
|
+
const parseMultipart = createMulterSingleMiddleware(maxBytes, fieldName);
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
name: 'upload',
|
|
122
|
+
version: '1.0.0',
|
|
123
|
+
description: 'Multipart file uploads with pluggable storage',
|
|
124
|
+
|
|
125
|
+
onRoutesReady(ctx) {
|
|
126
|
+
const { app } = ctx;
|
|
127
|
+
app.set('webspresso.uploadPath', normalizedPath);
|
|
128
|
+
|
|
129
|
+
const handler = (req, res) => {
|
|
130
|
+
parseMultipart(req, res, async (err) => {
|
|
131
|
+
if (err) {
|
|
132
|
+
const { status, message } = multerErrorResponse(err);
|
|
133
|
+
return res.status(status).json({ error: message, message });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const file = req.file;
|
|
137
|
+
if (!file || !file.buffer) {
|
|
138
|
+
return res.status(400).json({ error: 'No file uploaded', message: 'No file uploaded' });
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const mime = file.mimetype || 'application/octet-stream';
|
|
142
|
+
const originalName = file.originalname || 'upload';
|
|
143
|
+
const ext = extensionFromName(originalName);
|
|
144
|
+
|
|
145
|
+
if (mimeAllowlist && mimeAllowlist.length && !mimeAllowlist.includes(mime)) {
|
|
146
|
+
return res.status(415).json({ error: 'MIME type not allowed', message: 'MIME type not allowed' });
|
|
147
|
+
}
|
|
148
|
+
if (
|
|
149
|
+
extensionAllowlist &&
|
|
150
|
+
extensionAllowlist.length &&
|
|
151
|
+
(!ext || !extensionAllowlist.includes(ext))
|
|
152
|
+
) {
|
|
153
|
+
return res.status(400).json({ error: 'File extension not allowed', message: 'File extension not allowed' });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
const result = await provider.put({
|
|
158
|
+
buffer: file.buffer,
|
|
159
|
+
originalName,
|
|
160
|
+
mimeType: mime,
|
|
161
|
+
size: file.size,
|
|
162
|
+
req,
|
|
163
|
+
});
|
|
164
|
+
res.json({
|
|
165
|
+
url: result.publicUrl,
|
|
166
|
+
publicUrl: result.publicUrl,
|
|
167
|
+
key: result.key,
|
|
168
|
+
});
|
|
169
|
+
} catch (e) {
|
|
170
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
171
|
+
res.status(500).json({ error: message, message });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
ctx.addRoute('post', normalizedPath, ...mw, handler);
|
|
177
|
+
|
|
178
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
179
|
+
console.log(` Upload plugin: POST ${normalizedPath}`);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
module.exports = {
|
|
186
|
+
uploadPlugin,
|
|
187
|
+
createLocalFileProvider,
|
|
188
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default upload storage: write files to local disk and expose a public URL path.
|
|
3
|
+
* @module plugins/upload/local-file-provider
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs/promises');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const { randomBytes } = require('crypto');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Common MIME → canonical extension (lowercase, with leading dot).
|
|
12
|
+
* Used so stored filenames match the MIME the server accepted (see uploadPlugin mimeAllowlist).
|
|
13
|
+
* Does not verify file content; spoofed MIME is a separate concern (allowlist + optional magic-byte later).
|
|
14
|
+
*/
|
|
15
|
+
const MIME_TO_EXT = {
|
|
16
|
+
'image/jpeg': '.jpg',
|
|
17
|
+
'image/png': '.png',
|
|
18
|
+
'image/gif': '.gif',
|
|
19
|
+
'image/webp': '.webp',
|
|
20
|
+
'image/svg+xml': '.svg',
|
|
21
|
+
'image/avif': '.avif',
|
|
22
|
+
'application/pdf': '.pdf',
|
|
23
|
+
'text/plain': '.txt',
|
|
24
|
+
'text/csv': '.csv',
|
|
25
|
+
'text/html': '.html',
|
|
26
|
+
'application/json': '.json',
|
|
27
|
+
'application/zip': '.zip',
|
|
28
|
+
'application/gzip': '.gz',
|
|
29
|
+
'video/mp4': '.mp4',
|
|
30
|
+
'video/webm': '.webm',
|
|
31
|
+
'audio/mpeg': '.mp3',
|
|
32
|
+
'audio/webm': '.weba',
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Normalize public URL prefix (no trailing slash).
|
|
37
|
+
* @param {string} [publicBasePath]
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
function normalizePublicBase(publicBasePath) {
|
|
41
|
+
let p = (publicBasePath == null || publicBasePath === '' ? '/uploads' : String(publicBasePath)).trim();
|
|
42
|
+
p = p.replace(/\/+$/, '');
|
|
43
|
+
if (!p.startsWith('/')) {
|
|
44
|
+
p = `/${p}`;
|
|
45
|
+
}
|
|
46
|
+
return p || '/uploads';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} [mimeType]
|
|
51
|
+
* @returns {string|null} extension with leading dot, or null if unknown
|
|
52
|
+
*/
|
|
53
|
+
function canonicalExtFromMime(mimeType) {
|
|
54
|
+
if (!mimeType || typeof mimeType !== 'string') return null;
|
|
55
|
+
const base = mimeType.split(';')[0].trim().toLowerCase();
|
|
56
|
+
return MIME_TO_EXT[base] || null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Prefer a safe extension from the filename; align with MIME when we have a mapping.
|
|
61
|
+
* @param {string} [mimeType]
|
|
62
|
+
* @param {string} extFromName - already normalized (lowercase, with dot or '')
|
|
63
|
+
* @returns {string} extension with leading dot or empty string
|
|
64
|
+
*/
|
|
65
|
+
function resolveStoredExtension(mimeType, extFromName) {
|
|
66
|
+
const fromMime = canonicalExtFromMime(mimeType);
|
|
67
|
+
if (fromMime) {
|
|
68
|
+
if (!extFromName || extFromName !== fromMime) {
|
|
69
|
+
return fromMime;
|
|
70
|
+
}
|
|
71
|
+
return extFromName;
|
|
72
|
+
}
|
|
73
|
+
return extFromName || '';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} [options]
|
|
78
|
+
* @param {string} [options.destDir] — Absolute or cwd-relative directory for stored files
|
|
79
|
+
* @param {string} [options.publicBasePath='/uploads'] — URL prefix (first segment of public URL)
|
|
80
|
+
* @returns {import('./index').UploadStorageProvider}
|
|
81
|
+
*/
|
|
82
|
+
function createLocalFileProvider(options = {}) {
|
|
83
|
+
const destDir = path.resolve(
|
|
84
|
+
options.destDir || path.join(process.cwd(), 'public', 'uploads')
|
|
85
|
+
);
|
|
86
|
+
const publicBase = normalizePublicBase(options.publicBasePath);
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
async put({ buffer, originalName, mimeType, size, req }) {
|
|
90
|
+
void size;
|
|
91
|
+
void req;
|
|
92
|
+
await fs.mkdir(destDir, { recursive: true });
|
|
93
|
+
|
|
94
|
+
const base = path.basename(originalName || 'upload');
|
|
95
|
+
let ext = path.extname(base).toLowerCase();
|
|
96
|
+
if (!/^\.[a-z0-9]{1,12}$/.test(ext)) {
|
|
97
|
+
ext = '';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
ext = resolveStoredExtension(mimeType, ext);
|
|
101
|
+
|
|
102
|
+
const storedName = `${Date.now()}-${randomBytes(8).toString('hex')}${ext}`;
|
|
103
|
+
const target = path.join(destDir, storedName);
|
|
104
|
+
const resolvedDest = path.resolve(destDir);
|
|
105
|
+
if (!target.startsWith(resolvedDest + path.sep) && target !== resolvedDest) {
|
|
106
|
+
throw new Error('Invalid storage path');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await fs.writeFile(target, buffer);
|
|
110
|
+
|
|
111
|
+
const publicUrl = `${publicBase}/${storedName}`.replace(/\/{2,}/g, '/');
|
|
112
|
+
return { publicUrl, key: storedName };
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
module.exports = {
|
|
118
|
+
createLocalFileProvider,
|
|
119
|
+
normalizePublicBase,
|
|
120
|
+
canonicalExtFromMime,
|
|
121
|
+
resolveStoredExtension,
|
|
122
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* global Swup, SwupHeadPlugin, SwupScriptsPlugin, Alpine */
|
|
2
|
+
(function () {
|
|
3
|
+
if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined' || typeof Alpine === 'undefined') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function ignoreVisit(url, ctx) {
|
|
8
|
+
var el = ctx && ctx.el;
|
|
9
|
+
if (el && el.closest && el.closest('[data-no-swup]')) return true;
|
|
10
|
+
try {
|
|
11
|
+
var u = new URL(url, window.location.origin);
|
|
12
|
+
var p = u.pathname;
|
|
13
|
+
if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var swup = new Swup({
|
|
21
|
+
containers: ['#swup'],
|
|
22
|
+
plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
|
|
23
|
+
ignoreVisit: ignoreVisit,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
swup.hooks.on('content:replace', function () {
|
|
27
|
+
var root = document.querySelector('#swup');
|
|
28
|
+
if (root && typeof Alpine.initTree === 'function') {
|
|
29
|
+
Alpine.initTree(root);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
window.__webspressoSwup = swup;
|
|
34
|
+
})();
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/* global Swup, SwupHeadPlugin, SwupScriptsPlugin */
|
|
2
|
+
(function () {
|
|
3
|
+
if (typeof Swup === 'undefined' || typeof SwupHeadPlugin === 'undefined' || typeof SwupScriptsPlugin === 'undefined') {
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function ignoreVisit(url, ctx) {
|
|
8
|
+
var el = ctx && ctx.el;
|
|
9
|
+
if (el && el.closest && el.closest('[data-no-swup]')) return true;
|
|
10
|
+
try {
|
|
11
|
+
var u = new URL(url, window.location.origin);
|
|
12
|
+
var p = u.pathname;
|
|
13
|
+
if (p.indexOf('/_admin') === 0 || p.indexOf('/_webspresso') === 0) return true;
|
|
14
|
+
} catch (e) {
|
|
15
|
+
/* ignore */
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
var swup = new Swup({
|
|
21
|
+
containers: ['#swup'],
|
|
22
|
+
plugins: [new SwupHeadPlugin(), new SwupScriptsPlugin()],
|
|
23
|
+
ignoreVisit: ignoreVisit,
|
|
24
|
+
});
|
|
25
|
+
window.__webspressoSwup = swup;
|
|
26
|
+
})();
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mount Express routes that serve vendored Alpine / Swup UMD builds from node_modules
|
|
3
|
+
* and framework bootstrap scripts from src/client-runtime/.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const express = require('express');
|
|
8
|
+
|
|
9
|
+
const CLIENT_RUNTIME_BASE = '/__webspresso/client-runtime';
|
|
10
|
+
|
|
11
|
+
/** Resolve a file next to the package's resolved main entry (works with package "exports"). */
|
|
12
|
+
function pkgFileFromMain(pkg, ...segments) {
|
|
13
|
+
const entry = require.resolve(pkg);
|
|
14
|
+
return path.join(path.dirname(entry), ...segments);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {import('express').Express} app
|
|
19
|
+
* @param {{ alpine: boolean, swup: boolean }} flags
|
|
20
|
+
*/
|
|
21
|
+
function mountClientRuntime(app, flags) {
|
|
22
|
+
if (!flags || (!flags.alpine && !flags.swup)) return;
|
|
23
|
+
|
|
24
|
+
const router = express.Router();
|
|
25
|
+
|
|
26
|
+
function send(res, filePath) {
|
|
27
|
+
res.type('application/javascript');
|
|
28
|
+
res.sendFile(filePath);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (flags.alpine) {
|
|
32
|
+
router.get('/alpine.min.js', (req, res) => {
|
|
33
|
+
send(res, pkgFileFromMain('alpinejs', 'cdn.min.js'));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (flags.swup) {
|
|
38
|
+
router.get('/swup.umd.js', (req, res) => {
|
|
39
|
+
send(res, pkgFileFromMain('swup', 'Swup.umd.js'));
|
|
40
|
+
});
|
|
41
|
+
router.get('/swup-head-plugin.umd.js', (req, res) => {
|
|
42
|
+
send(res, pkgFileFromMain('@swup/head-plugin', 'index.umd.js'));
|
|
43
|
+
});
|
|
44
|
+
router.get('/swup-scripts-plugin.umd.js', (req, res) => {
|
|
45
|
+
send(res, pkgFileFromMain('@swup/scripts-plugin', 'index.umd.js'));
|
|
46
|
+
});
|
|
47
|
+
const runtimeDir = __dirname;
|
|
48
|
+
if (flags.alpine) {
|
|
49
|
+
router.get('/bootstrap-alpine-swup.js', (req, res) => {
|
|
50
|
+
send(res, path.join(runtimeDir, 'bootstrap-alpine-swup.js'));
|
|
51
|
+
});
|
|
52
|
+
} else {
|
|
53
|
+
router.get('/bootstrap-swup.js', (req, res) => {
|
|
54
|
+
send(res, path.join(runtimeDir, 'bootstrap-swup.js'));
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
app.use(CLIENT_RUNTIME_BASE, router);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
mountClientRuntime,
|
|
64
|
+
CLIENT_RUNTIME_BASE,
|
|
65
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve clientRuntime flags from createApp({ clientRuntime }) and optional env overrides.
|
|
3
|
+
* Env: WEBSPRESSO_ALPINE=1|true, WEBSPRESSO_SWUP=1|true (override explicit false from options when set).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
function envTruthy(name) {
|
|
7
|
+
const v = process.env[name];
|
|
8
|
+
if (v == null || v === '') return undefined;
|
|
9
|
+
return v === '1' || /^true$/i.test(String(v));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function optionEnabled(v) {
|
|
13
|
+
if (v === true) return true;
|
|
14
|
+
if (v && typeof v === 'object') return true;
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {object} [options]
|
|
20
|
+
* @param {object} [options.clientRuntime]
|
|
21
|
+
* @param {boolean|object} [options.clientRuntime.alpine]
|
|
22
|
+
* @param {boolean|object} [options.clientRuntime.swup]
|
|
23
|
+
* @returns {{ alpine: boolean, swup: boolean }}
|
|
24
|
+
*/
|
|
25
|
+
function resolveClientRuntime(options = {}) {
|
|
26
|
+
const cr = options.clientRuntime;
|
|
27
|
+
let alpine = false;
|
|
28
|
+
let swup = false;
|
|
29
|
+
if (cr && typeof cr === 'object') {
|
|
30
|
+
alpine = optionEnabled(cr.alpine);
|
|
31
|
+
swup = optionEnabled(cr.swup);
|
|
32
|
+
}
|
|
33
|
+
const envA = envTruthy('WEBSPRESSO_ALPINE');
|
|
34
|
+
const envS = envTruthy('WEBSPRESSO_SWUP');
|
|
35
|
+
if (envA !== undefined) alpine = envA;
|
|
36
|
+
if (envS !== undefined) swup = envS;
|
|
37
|
+
return { alpine, swup };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { resolveClientRuntime };
|
package/src/file-router.js
CHANGED
|
@@ -365,10 +365,22 @@ function resolveMiddlewares(middlewareConfig, middlewareRegistry = {}) {
|
|
|
365
365
|
* @param {Object} options.pluginManager - Plugin manager instance
|
|
366
366
|
* @param {boolean} options.silent - Suppress console output
|
|
367
367
|
* @param {Object} options.db - Database instance (exposed as ctx.db in load/meta)
|
|
368
|
+
* @param {{ alpine?: boolean, swup?: boolean }} [options.clientRuntime] - Passed to Nunjucks as `clientRuntime` (default both false)
|
|
368
369
|
* @returns {Array} Route metadata for plugins
|
|
369
370
|
*/
|
|
370
371
|
function mountPages(app, options) {
|
|
371
|
-
const {
|
|
372
|
+
const {
|
|
373
|
+
pagesDir,
|
|
374
|
+
nunjucks,
|
|
375
|
+
middlewares = {},
|
|
376
|
+
pluginManager = null,
|
|
377
|
+
silent = false,
|
|
378
|
+
db = null,
|
|
379
|
+
clientRuntime: clientRuntimeOpt = null,
|
|
380
|
+
} = options;
|
|
381
|
+
const clientRuntime = clientRuntimeOpt && typeof clientRuntimeOpt === 'object'
|
|
382
|
+
? { alpine: !!clientRuntimeOpt.alpine, swup: !!clientRuntimeOpt.swup }
|
|
383
|
+
: { alpine: false, swup: false };
|
|
372
384
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
373
385
|
const log = silent ? () => {} : console.log.bind(console);
|
|
374
386
|
|
|
@@ -548,7 +560,8 @@ function mountPages(app, options) {
|
|
|
548
560
|
indexable: true,
|
|
549
561
|
canonical: null
|
|
550
562
|
},
|
|
551
|
-
fsy: { ...baseHelpers, ...pluginHelpers }
|
|
563
|
+
fsy: { ...baseHelpers, ...pluginHelpers },
|
|
564
|
+
clientRuntime,
|
|
552
565
|
};
|
|
553
566
|
|
|
554
567
|
// Execute hooks: onRequest
|
|
@@ -612,6 +625,7 @@ function mountPages(app, options) {
|
|
|
612
625
|
locale: ctx.locale,
|
|
613
626
|
t: ctx.t,
|
|
614
627
|
fsy: ctx.fsy,
|
|
628
|
+
clientRuntime: ctx.clientRuntime,
|
|
615
629
|
req: {
|
|
616
630
|
path: req.path,
|
|
617
631
|
query: req.query,
|
package/src/server.js
CHANGED
|
@@ -9,6 +9,8 @@ const nunjucks = require('nunjucks');
|
|
|
9
9
|
const timeout = require('connect-timeout');
|
|
10
10
|
|
|
11
11
|
const { setAppContext } = require('./app-context');
|
|
12
|
+
const { mountClientRuntime } = require('./client-runtime/mount');
|
|
13
|
+
const { resolveClientRuntime } = require('./client-runtime/resolve');
|
|
12
14
|
const { mountPages } = require('./file-router');
|
|
13
15
|
const { configureAssets, createHelpers, getScriptInjector } = require('./helpers');
|
|
14
16
|
const { createPluginManager } = require('./plugin-manager');
|
|
@@ -258,6 +260,7 @@ function haltOnTimedout(req, res, next) {
|
|
|
258
260
|
* @param {string|boolean} options.timeout - Request timeout (default: '30s', false to disable)
|
|
259
261
|
* @param {Object} options.auth - Authentication manager instance (from createAuth)
|
|
260
262
|
* @param {Object} options.db - Database instance (exposed as ctx.db to plugins)
|
|
263
|
+
* @param {Object} [options.clientRuntime] - Optional client assets: `{ alpine?: boolean|object, swup?: boolean|object }`. Overridable by env `WEBSPRESSO_ALPINE` / `WEBSPRESSO_SWUP` (=1 or true). Serves `/__webspresso/client-runtime/*` when either flag is on.
|
|
261
264
|
* @param {function(import('express').Express, Object): void} [options.setupRoutes] - Called after file routes and plugins, before 404 handler
|
|
262
265
|
* @returns {Object} { app, nunjucksEnv, pluginManager, authMiddleware }
|
|
263
266
|
*/
|
|
@@ -294,6 +297,8 @@ function createApp(options = {}) {
|
|
|
294
297
|
throw new Error('pagesDir is required');
|
|
295
298
|
}
|
|
296
299
|
|
|
300
|
+
const clientRuntime = resolveClientRuntime(options);
|
|
301
|
+
|
|
297
302
|
setAppContext({ db: options.db ?? null });
|
|
298
303
|
|
|
299
304
|
const app = express();
|
|
@@ -378,7 +383,9 @@ function createApp(options = {}) {
|
|
|
378
383
|
etag: true
|
|
379
384
|
}));
|
|
380
385
|
}
|
|
381
|
-
|
|
386
|
+
|
|
387
|
+
mountClientRuntime(app, clientRuntime);
|
|
388
|
+
|
|
382
389
|
// Configure Nunjucks
|
|
383
390
|
const templateDirs = viewsDir ? [pagesDir, viewsDir] : [pagesDir];
|
|
384
391
|
|
|
@@ -439,7 +446,8 @@ function createApp(options = {}) {
|
|
|
439
446
|
middlewares,
|
|
440
447
|
pluginManager,
|
|
441
448
|
silent: isTest,
|
|
442
|
-
db: options.db ?? null
|
|
449
|
+
db: options.db ?? null,
|
|
450
|
+
clientRuntime,
|
|
443
451
|
});
|
|
444
452
|
|
|
445
453
|
// Set route metadata in plugin manager
|
|
@@ -480,6 +488,7 @@ function createApp(options = {}) {
|
|
|
480
488
|
authMiddleware,
|
|
481
489
|
pluginManager,
|
|
482
490
|
options,
|
|
491
|
+
clientRuntime,
|
|
483
492
|
});
|
|
484
493
|
}
|
|
485
494
|
|