webspresso 0.0.97 → 0.0.99
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 +163 -5
- package/.agents/AGENTS.md +1 -1
- package/.agents/ARCHITECTURE.md +10 -0
- package/.agents/AUTH.md +169 -0
- package/.agents/CLI.md +195 -1
- package/.agents/ORM.md +88 -4
- package/.agents/PLUGINS.md +221 -9
- package/.agents/QUEUE.md +127 -0
- package/.agents/ROUTING.md +127 -7
- package/AGENTS.md +1 -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/auth/index.js +60 -0
- package/core/auth/manager.js +134 -0
- package/core/auth/middleware.js +88 -0
- package/core/auth/oauth.js +517 -0
- package/core/auth/qrcode.js +499 -0
- package/core/auth/totp.js +504 -0
- 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/eager-loader.js +144 -10
- package/core/orm/index.js +20 -7
- package/core/orm/model.js +53 -7
- package/core/orm/query-builder.js +90 -4
- package/core/orm/repository.js +44 -3
- package/core/orm/search.js +213 -0
- 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/cron.js +331 -0
- package/core/queue/index.js +8 -1
- package/core/queue/manager.js +205 -1
- package/core/queue/scheduler.js +359 -0
- package/core/sse/index.js +19 -0
- package/core/sse/stream.js +430 -0
- package/core/ssr/stream.js +11 -0
- package/index.d.ts +1188 -10
- package/index.js +88 -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 +333 -36
- 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 +248 -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/client/verify-spa-parts.js +2 -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/field-renderers/file-upload.js +187 -104
- package/plugins/admin-panel/field-renderers/index.js +2 -0
- 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/api-key/crypto.js +101 -0
- package/plugins/api-key/index.js +288 -0
- package/plugins/api-key/middleware.js +461 -0
- package/plugins/api-key/migration-template.js +48 -0
- package/plugins/api-key/model.js +50 -0
- package/plugins/api-key/scopes.js +125 -0
- package/plugins/audit-log/admin-component.js +487 -59
- package/plugins/audit-log/api-handlers.js +148 -0
- package/plugins/audit-log/index.js +153 -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/file-manager/admin-component.js +20 -4
- package/plugins/file-manager/index.js +21 -4
- package/plugins/index.js +10 -0
- package/plugins/oauth/index.js +338 -0
- package/plugins/orm-cache-admin/index.js +4 -2
- package/plugins/queue/index.js +15 -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 +4 -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/discovery/file-route-parser.js +44 -1
- package/src/discovery/scan-routes.js +2 -2
- package/src/file-router.js +347 -162
- package/src/helpers.js +967 -98
- package/src/njk-frontmatter.js +12 -0
- package/src/pages/page-loader.js +61 -11
- package/src/routing/cascading.js +378 -0
- package/src/routing/middleware-resolver.js +92 -0
- package/src/server.js +635 -63
package/src/njk-frontmatter.js
CHANGED
|
@@ -152,9 +152,20 @@ function loadNjkRouteTemplate(absPath, isDev) {
|
|
|
152
152
|
return fresh;
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
const clearHooks = new Set();
|
|
156
|
+
|
|
157
|
+
function registerClearHook(fn) {
|
|
158
|
+
if (typeof fn === 'function') clearHooks.add(fn);
|
|
159
|
+
}
|
|
160
|
+
|
|
155
161
|
function clearNjkFrontmatterCaches() {
|
|
156
162
|
prodRouteCache.clear();
|
|
157
163
|
devRouteCache.clear();
|
|
164
|
+
for (const fn of clearHooks) {
|
|
165
|
+
try {
|
|
166
|
+
fn();
|
|
167
|
+
} catch (_) {}
|
|
168
|
+
}
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
module.exports = {
|
|
@@ -162,5 +173,6 @@ module.exports = {
|
|
|
162
173
|
frontmatterToPatches,
|
|
163
174
|
loadNjkRouteTemplate,
|
|
164
175
|
clearNjkFrontmatterCaches,
|
|
176
|
+
registerClearHook,
|
|
165
177
|
extractFrontmatterBlock,
|
|
166
178
|
};
|
package/src/pages/page-loader.js
CHANGED
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
10
|
const path = require('path');
|
|
11
|
-
const { createHelpers } = require('../helpers');
|
|
12
|
-
const {
|
|
11
|
+
const { createHelpers, getAssetManager } = require('../helpers');
|
|
12
|
+
const {
|
|
13
|
+
loadI18n,
|
|
14
|
+
createTranslator,
|
|
15
|
+
detectLocale,
|
|
16
|
+
resolveMiddlewares,
|
|
17
|
+
translatorCache,
|
|
18
|
+
resolvePageAssets,
|
|
19
|
+
applyPageAssetsToTemplateData,
|
|
20
|
+
} = require('../file-router');
|
|
13
21
|
|
|
14
22
|
const EMPTY_ARRAY = Object.freeze([]);
|
|
15
23
|
|
|
@@ -111,13 +119,29 @@ function createPageHandler(descriptor, context) {
|
|
|
111
119
|
|
|
112
120
|
// i18n
|
|
113
121
|
const defaultLocale = process.env.DEFAULT_LOCALE || 'en';
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
122
|
+
let t;
|
|
123
|
+
if (!isDev && translatorCache) {
|
|
124
|
+
const transKey = `${pagesDir}::::${locale}::${defaultLocale}`;
|
|
125
|
+
t = translatorCache.get(transKey);
|
|
126
|
+
if (!t) {
|
|
127
|
+
const translations = loadI18n(pagesDir, '', locale, false);
|
|
128
|
+
const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, false) : undefined;
|
|
129
|
+
t = createTranslator(translations, {
|
|
130
|
+
locale,
|
|
131
|
+
fallbackTranslations,
|
|
132
|
+
fallbackLocale: defaultLocale,
|
|
133
|
+
});
|
|
134
|
+
translatorCache.set(transKey, t);
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
const translations = loadI18n(pagesDir, '', locale, isDev);
|
|
138
|
+
const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, isDev) : undefined;
|
|
139
|
+
t = createTranslator(translations, {
|
|
140
|
+
locale,
|
|
141
|
+
fallbackTranslations,
|
|
142
|
+
fallbackLocale: defaultLocale,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
121
145
|
|
|
122
146
|
const pageCtx = {
|
|
123
147
|
req,
|
|
@@ -163,10 +187,36 @@ function createPageHandler(descriptor, context) {
|
|
|
163
187
|
const templateExists = hasNjkTemplate !== null ? hasNjkTemplate : fs.existsSync(njkCandidate);
|
|
164
188
|
|
|
165
189
|
if (templateExists && nunjucks) {
|
|
190
|
+
let pageHead = headData;
|
|
191
|
+
let pageAssetsActive = false;
|
|
192
|
+
const pageAssetsOpt = context.pageAssets || (appOptions && appOptions.pageAssets);
|
|
193
|
+
if (pageAssetsOpt) {
|
|
194
|
+
const pageAssetsResolved = resolvePageAssets(pageAssetsOpt);
|
|
195
|
+
if (pageAssetsResolved.enabled) {
|
|
196
|
+
const pageAssetBundle = applyPageAssetsToTemplateData(
|
|
197
|
+
pageAssetsResolved,
|
|
198
|
+
loadedData,
|
|
199
|
+
{
|
|
200
|
+
file: descriptor.source || descriptor.file,
|
|
201
|
+
routePath: descriptor.path,
|
|
202
|
+
},
|
|
203
|
+
fsy?.assetManager || getAssetManager()
|
|
204
|
+
);
|
|
205
|
+
loadedData = pageAssetBundle.data;
|
|
206
|
+
pageHead = {
|
|
207
|
+
...headData,
|
|
208
|
+
stylesheets: [...(headData.stylesheets || []), ...(pageAssetBundle.pageHead?.stylesheets || [])],
|
|
209
|
+
scripts: [...(headData.scripts || []), ...(pageAssetBundle.pageHead?.scripts || [])],
|
|
210
|
+
};
|
|
211
|
+
pageAssetsActive = true;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
166
215
|
const templateData = {
|
|
167
216
|
...loadedData,
|
|
168
|
-
head:
|
|
169
|
-
pageHead
|
|
217
|
+
head: pageHead,
|
|
218
|
+
pageHead,
|
|
219
|
+
pageAssets: pageAssetsActive || Boolean(loadedData.pageAssets),
|
|
170
220
|
fsy,
|
|
171
221
|
locale,
|
|
172
222
|
t,
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webspresso Cascading Middleware & Nested Layout Engine
|
|
3
|
+
* Provides folder-level _middleware.js inheritance and _layout.njk template nesting
|
|
4
|
+
* @module src/routing/cascading
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
const { resolveMiddlewares } = require('./middleware-resolver');
|
|
12
|
+
const { loadNjkRouteTemplate } = require('../njk-frontmatter');
|
|
13
|
+
|
|
14
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
15
|
+
|
|
16
|
+
// Caches for production mode (key: directory path)
|
|
17
|
+
const middlewareFileCache = new Map();
|
|
18
|
+
const layoutFileCache = new Map();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns ancestor directory segments from root down to file's directory.
|
|
22
|
+
* E.g., 'dashboard/settings/profile.njk' -> ['', 'dashboard', 'dashboard/settings']
|
|
23
|
+
*
|
|
24
|
+
* @param {string} relativeFilePath
|
|
25
|
+
* @returns {string[]}
|
|
26
|
+
*/
|
|
27
|
+
function getDirectoryChain(relativeFilePath) {
|
|
28
|
+
const normalized = relativeFilePath.split(/[/\\]/).join('/');
|
|
29
|
+
const dir = path.posix.dirname(normalized);
|
|
30
|
+
|
|
31
|
+
if (dir === '.' || dir === '') {
|
|
32
|
+
return [''];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const parts = dir.split('/');
|
|
36
|
+
const chain = [''];
|
|
37
|
+
let current = '';
|
|
38
|
+
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
if (!part) continue;
|
|
41
|
+
current = current ? `${current}/${part}` : part;
|
|
42
|
+
chain.push(current);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return chain;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Check if a template content explicitly declares its own layout inheritance via `{% extends ... %}`
|
|
50
|
+
*
|
|
51
|
+
* @param {string} [templateBody]
|
|
52
|
+
* @param {string} [fullPath]
|
|
53
|
+
* @returns {boolean}
|
|
54
|
+
*/
|
|
55
|
+
function templateHasExtends(templateBody, fullPath) {
|
|
56
|
+
if (typeof templateBody === 'string' && /\{%\s*extends\s+/.test(templateBody)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
if (fullPath && fs.existsSync(fullPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
62
|
+
return /\{%\s*extends\s+/.test(content);
|
|
63
|
+
} catch (_) {}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Discover all existing _middleware.js files in directory chain (from root to target dir).
|
|
70
|
+
*
|
|
71
|
+
* @param {string} baseDir - Base pages directory
|
|
72
|
+
* @param {string} relativeFilePath - Relative file path
|
|
73
|
+
* @param {boolean} [isDev=false]
|
|
74
|
+
* @returns {string[]} Absolute paths to existing _middleware.js files
|
|
75
|
+
*/
|
|
76
|
+
function findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev = false) {
|
|
77
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
78
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
79
|
+
|
|
80
|
+
if (!isDev && middlewareFileCache.has(cacheKey)) {
|
|
81
|
+
return middlewareFileCache.get(cacheKey);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const existingFiles = [];
|
|
85
|
+
for (const dirSegment of chain) {
|
|
86
|
+
const middlewarePath = dirSegment === ''
|
|
87
|
+
? path.join(baseDir, '_middleware.js')
|
|
88
|
+
: path.join(baseDir, ...dirSegment.split('/'), '_middleware.js');
|
|
89
|
+
|
|
90
|
+
if (fs.existsSync(middlewarePath)) {
|
|
91
|
+
existingFiles.push(middlewarePath);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!isDev) {
|
|
96
|
+
middlewareFileCache.set(cacheKey, existingFiles);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return existingFiles;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Loads and resolves cascading middlewares for a given route file.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} baseDir - Base directory (e.g. pagesDir)
|
|
106
|
+
* @param {string} relativeFilePath - Relative file path
|
|
107
|
+
* @param {Object} middlewareRegistry - Available named middlewares
|
|
108
|
+
* @param {boolean} [isDev=false]
|
|
109
|
+
* @returns {Function[]} Ordered list of resolved Express middleware functions
|
|
110
|
+
*/
|
|
111
|
+
function loadCascadingMiddlewares(baseDir, relativeFilePath, middlewareRegistry = {}, isDev = false) {
|
|
112
|
+
const files = findCascadingMiddlewareFiles(baseDir, relativeFilePath, isDev);
|
|
113
|
+
if (files.length === 0) return EMPTY_ARRAY;
|
|
114
|
+
|
|
115
|
+
const resolved = [];
|
|
116
|
+
|
|
117
|
+
for (const file of files) {
|
|
118
|
+
try {
|
|
119
|
+
if (isDev && require.cache[require.resolve(file)]) {
|
|
120
|
+
delete require.cache[require.resolve(file)];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const mod = require(file);
|
|
124
|
+
const candidate = mod?.default || mod;
|
|
125
|
+
const raw = typeof candidate === 'function'
|
|
126
|
+
? candidate
|
|
127
|
+
: (candidate?.middleware || mod?.middleware || candidate);
|
|
128
|
+
|
|
129
|
+
if (!raw) continue;
|
|
130
|
+
|
|
131
|
+
if (typeof raw === 'function') {
|
|
132
|
+
resolved.push(raw);
|
|
133
|
+
} else if (Array.isArray(raw)) {
|
|
134
|
+
const chain = resolveMiddlewares(raw, middlewareRegistry);
|
|
135
|
+
resolved.push(...chain);
|
|
136
|
+
} else if (typeof raw === 'string') {
|
|
137
|
+
const chain = resolveMiddlewares([raw], middlewareRegistry);
|
|
138
|
+
resolved.push(...chain);
|
|
139
|
+
}
|
|
140
|
+
} catch (err) {
|
|
141
|
+
console.error(`[webspresso] Failed to load middleware from "${file}":`, err.message);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return resolved;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Run a single Express middleware with support for async functions, early completion (res.finish/close),
|
|
150
|
+
* and standard next()/next(err).
|
|
151
|
+
*
|
|
152
|
+
* @param {Function} mw
|
|
153
|
+
* @param {import('http').IncomingMessage} req
|
|
154
|
+
* @param {import('http').ServerResponse} res
|
|
155
|
+
* @returns {Promise<void>}
|
|
156
|
+
*/
|
|
157
|
+
function runMiddleware(mw, req, res) {
|
|
158
|
+
if (res.headersSent) return Promise.resolve();
|
|
159
|
+
|
|
160
|
+
return new Promise((resolve, reject) => {
|
|
161
|
+
let settled = false;
|
|
162
|
+
|
|
163
|
+
const onFinish = () => {
|
|
164
|
+
if (!settled) {
|
|
165
|
+
settled = true;
|
|
166
|
+
cleanup();
|
|
167
|
+
resolve();
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const cleanup = () => {
|
|
172
|
+
res.removeListener('finish', onFinish);
|
|
173
|
+
res.removeListener('close', onFinish);
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
res.once('finish', onFinish);
|
|
177
|
+
res.once('close', onFinish);
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const result = mw(req, res, (err) => {
|
|
181
|
+
if (settled) return;
|
|
182
|
+
settled = true;
|
|
183
|
+
cleanup();
|
|
184
|
+
if (err) reject(err);
|
|
185
|
+
else resolve();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
if (result && typeof result.then === 'function') {
|
|
189
|
+
result.then(
|
|
190
|
+
() => {
|
|
191
|
+
if (!settled) {
|
|
192
|
+
settled = true;
|
|
193
|
+
cleanup();
|
|
194
|
+
resolve();
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
(err) => {
|
|
198
|
+
if (!settled) {
|
|
199
|
+
settled = true;
|
|
200
|
+
cleanup();
|
|
201
|
+
reject(err);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (!settled) {
|
|
208
|
+
settled = true;
|
|
209
|
+
cleanup();
|
|
210
|
+
reject(err);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Run a chain of middlewares sequentially. Halts early if response is sent.
|
|
218
|
+
*
|
|
219
|
+
* @param {Function[]} chain
|
|
220
|
+
* @param {import('http').IncomingMessage} req
|
|
221
|
+
* @param {import('http').ServerResponse} res
|
|
222
|
+
* @returns {Promise<void>}
|
|
223
|
+
*/
|
|
224
|
+
async function runMiddlewareChain(chain, req, res) {
|
|
225
|
+
if (!chain || chain.length === 0) return;
|
|
226
|
+
for (const mw of chain) {
|
|
227
|
+
if (res.headersSent) return;
|
|
228
|
+
await runMiddleware(mw, req, res);
|
|
229
|
+
if (res.headersSent) return;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Discover all existing _layout.njk files in directory chain (from root to target dir).
|
|
235
|
+
*
|
|
236
|
+
* @param {string} baseDir - Base pages directory
|
|
237
|
+
* @param {string} relativeFilePath - Relative file path
|
|
238
|
+
* @param {boolean} [isDev=false]
|
|
239
|
+
* @returns {Array<{ layoutRelPath: string, fullPath: string, configPath: string|null }>}
|
|
240
|
+
*/
|
|
241
|
+
function findCascadingLayouts(baseDir, relativeFilePath, isDev = false) {
|
|
242
|
+
const chain = getDirectoryChain(relativeFilePath);
|
|
243
|
+
const cacheKey = `${baseDir}::${chain.join('>')}`;
|
|
244
|
+
|
|
245
|
+
if (!isDev && layoutFileCache.has(cacheKey)) {
|
|
246
|
+
return layoutFileCache.get(cacheKey);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const layouts = [];
|
|
250
|
+
|
|
251
|
+
for (const dirSegment of chain) {
|
|
252
|
+
const layoutNjkName = '_layout.njk';
|
|
253
|
+
const layoutJsName = '_layout.js';
|
|
254
|
+
|
|
255
|
+
const layoutRelPath = dirSegment === ''
|
|
256
|
+
? layoutNjkName
|
|
257
|
+
: `${dirSegment}/${layoutNjkName}`;
|
|
258
|
+
|
|
259
|
+
const fullPath = dirSegment === ''
|
|
260
|
+
? path.join(baseDir, layoutNjkName)
|
|
261
|
+
: path.join(baseDir, ...dirSegment.split('/'), layoutNjkName);
|
|
262
|
+
|
|
263
|
+
const fullJsPath = dirSegment === ''
|
|
264
|
+
? path.join(baseDir, layoutJsName)
|
|
265
|
+
: path.join(baseDir, ...dirSegment.split('/'), layoutJsName);
|
|
266
|
+
|
|
267
|
+
if (fs.existsSync(fullPath)) {
|
|
268
|
+
layouts.push({
|
|
269
|
+
layoutRelPath: layoutRelPath.split(path.sep).join('/'),
|
|
270
|
+
fullPath,
|
|
271
|
+
configPath: fs.existsSync(fullJsPath) ? fullJsPath : null,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (!isDev) {
|
|
277
|
+
layoutFileCache.set(cacheKey, layouts);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return layouts;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Executes load() functions from companion _layout.js files in top-down order (outermost first).
|
|
285
|
+
*
|
|
286
|
+
* @param {Array<{ configPath: string|null }>} layouts
|
|
287
|
+
* @param {import('http').IncomingMessage} req
|
|
288
|
+
* @param {Object} ctx - Route execution context
|
|
289
|
+
* @param {boolean} [isDev=false]
|
|
290
|
+
* @returns {Promise<void>}
|
|
291
|
+
*/
|
|
292
|
+
async function executeCascadingLayoutLoads(layouts, req, ctx, isDev = false) {
|
|
293
|
+
for (const layout of layouts) {
|
|
294
|
+
if (!layout.configPath) continue;
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
if (isDev && require.cache[require.resolve(layout.configPath)]) {
|
|
298
|
+
delete require.cache[require.resolve(layout.configPath)];
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const mod = require(layout.configPath);
|
|
302
|
+
const candidate = mod?.default || mod;
|
|
303
|
+
const loadFn = typeof candidate === 'function' ? candidate : candidate?.load;
|
|
304
|
+
|
|
305
|
+
if (typeof loadFn === 'function') {
|
|
306
|
+
const loadedData = await loadFn(req, ctx);
|
|
307
|
+
if (loadedData && typeof loadedData === 'object' && !Array.isArray(loadedData)) {
|
|
308
|
+
ctx.data = Object.assign({}, ctx.data, loadedData);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
} catch (err) {
|
|
312
|
+
console.error(`[webspresso] Error in layout loader "${layout.configPath}":`, err.message);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Wraps page HTML with discovered nested layouts (from innermost to outermost).
|
|
319
|
+
*
|
|
320
|
+
* @param {Object} nunjucksEnv - Nunjucks environment instance
|
|
321
|
+
* @param {Array<{ layoutRelPath: string, fullPath: string }>} layouts - Discovered layouts from outermost to innermost
|
|
322
|
+
* @param {string} pageHtml - Rendered child HTML
|
|
323
|
+
* @param {Object} renderContext - Full template render context
|
|
324
|
+
* @param {boolean} [isDev=false]
|
|
325
|
+
* @returns {string} Fully wrapped HTML
|
|
326
|
+
*/
|
|
327
|
+
function applyCascadingLayouts(nunjucksEnv, layouts, pageHtml, renderContext, isDev = false) {
|
|
328
|
+
if (!layouts || layouts.length === 0) {
|
|
329
|
+
return pageHtml;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
let currentHtml = pageHtml;
|
|
333
|
+
|
|
334
|
+
// Wrap from innermost layout up to outermost (reverse of chain)
|
|
335
|
+
const reversedLayouts = [...layouts].reverse();
|
|
336
|
+
|
|
337
|
+
for (const layout of reversedLayouts) {
|
|
338
|
+
try {
|
|
339
|
+
const layoutContext = Object.assign({}, renderContext, {
|
|
340
|
+
content: currentHtml,
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const njkTpl = loadNjkRouteTemplate(layout.fullPath, isDev);
|
|
344
|
+
if (njkTpl.dataPatch && Object.keys(njkTpl.dataPatch).length > 0) {
|
|
345
|
+
Object.assign(layoutContext, njkTpl.dataPatch);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (njkTpl.useStringRender && njkTpl.templateBody != null) {
|
|
349
|
+
currentHtml = nunjucksEnv.renderString(njkTpl.templateBody, layoutContext, { path: layout.fullPath });
|
|
350
|
+
} else {
|
|
351
|
+
currentHtml = nunjucksEnv.render(layout.layoutRelPath, layoutContext);
|
|
352
|
+
}
|
|
353
|
+
} catch (err) {
|
|
354
|
+
console.error(`[webspresso] Failed rendering layout "${layout.layoutRelPath}":`, err.message);
|
|
355
|
+
throw err;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return currentHtml;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function clearCascadingCaches() {
|
|
363
|
+
middlewareFileCache.clear();
|
|
364
|
+
layoutFileCache.clear();
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
module.exports = {
|
|
368
|
+
getDirectoryChain,
|
|
369
|
+
templateHasExtends,
|
|
370
|
+
findCascadingMiddlewareFiles,
|
|
371
|
+
loadCascadingMiddlewares,
|
|
372
|
+
runMiddleware,
|
|
373
|
+
runMiddlewareChain,
|
|
374
|
+
findCascadingLayouts,
|
|
375
|
+
executeCascadingLayoutLoads,
|
|
376
|
+
applyCascadingLayouts,
|
|
377
|
+
clearCascadingCaches,
|
|
378
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EMPTY_OBJECT = Object.freeze({});
|
|
4
|
+
const EMPTY_ARRAY = Object.freeze([]);
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* True when the registry entry is (options) => (req, res, next) => …
|
|
8
|
+
* Express handlers typically have length >= 2 (req, res) or 3 (req, res, next).
|
|
9
|
+
* @param {Function} fn
|
|
10
|
+
* @returns {boolean}
|
|
11
|
+
*/
|
|
12
|
+
function isMiddlewareFactory(fn) {
|
|
13
|
+
return typeof fn === 'function' && fn.length <= 1;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a named middleware from createApp({ middlewares }).
|
|
18
|
+
* @param {string} name
|
|
19
|
+
* @param {Function} entry
|
|
20
|
+
* @param {boolean} fromTuple - true when route used ['name', options]
|
|
21
|
+
* @param {unknown} tupleOptions - second element of the tuple (only when fromTuple)
|
|
22
|
+
* @param {Object} middlewareRegistry - for error messages
|
|
23
|
+
* @returns {Function} Express middleware
|
|
24
|
+
*/
|
|
25
|
+
function resolveNamedMiddleware(name, entry, fromTuple, tupleOptions, middlewareRegistry) {
|
|
26
|
+
if (!entry) {
|
|
27
|
+
throw new Error(`Middleware "${name}" not found in registry. Available: ${Object.keys(middlewareRegistry).join(', ') || 'none'}`);
|
|
28
|
+
}
|
|
29
|
+
if (typeof entry !== 'function') {
|
|
30
|
+
throw new Error(`Middleware "${name}" must be a function`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (fromTuple) {
|
|
34
|
+
if (!isMiddlewareFactory(entry)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`Middleware "${name}" must be a factory (options) => (req, res, next) => … when using ["${name}", options] tuple form`
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
const produced = entry(tupleOptions);
|
|
40
|
+
if (typeof produced !== 'function') {
|
|
41
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
42
|
+
}
|
|
43
|
+
return produced;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (isMiddlewareFactory(entry)) {
|
|
47
|
+
const produced = entry({});
|
|
48
|
+
if (typeof produced !== 'function') {
|
|
49
|
+
throw new Error(`Middleware factory "${name}" must return an Express middleware function`);
|
|
50
|
+
}
|
|
51
|
+
return produced;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return entry;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve middleware from config — functions, string names, or [name, options] tuples
|
|
59
|
+
* @param {Array} middlewareConfig - middleware functions, names, or ['name', options] tuples
|
|
60
|
+
* @param {Object} [middlewareRegistry] - Named middleware registry (plain handlers or option factories)
|
|
61
|
+
* @returns {Array<Function>} Array of resolved middleware functions
|
|
62
|
+
*/
|
|
63
|
+
function resolveMiddlewares(middlewareConfig, middlewareRegistry = EMPTY_OBJECT) {
|
|
64
|
+
if (!middlewareConfig || !Array.isArray(middlewareConfig) || middlewareConfig.length === 0) {
|
|
65
|
+
return EMPTY_ARRAY;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return middlewareConfig.map((mw, index) => {
|
|
69
|
+
if (typeof mw === 'function') {
|
|
70
|
+
return mw;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof mw === 'string') {
|
|
74
|
+
return resolveNamedMiddleware(mw, middlewareRegistry[mw], false, undefined, middlewareRegistry);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (Array.isArray(mw) && mw.length === 2 && typeof mw[0] === 'string') {
|
|
78
|
+
const name = mw[0];
|
|
79
|
+
return resolveNamedMiddleware(name, middlewareRegistry[name], true, mw[1], middlewareRegistry);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Invalid middleware at index ${index}: must be a function, string name, or [name, options] tuple`
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = {
|
|
89
|
+
isMiddlewareFactory,
|
|
90
|
+
resolveNamedMiddleware,
|
|
91
|
+
resolveMiddlewares,
|
|
92
|
+
};
|