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.
Files changed (136) hide show
  1. package/.agents/ADMIN.md +163 -5
  2. package/.agents/AGENTS.md +1 -1
  3. package/.agents/ARCHITECTURE.md +10 -0
  4. package/.agents/AUTH.md +169 -0
  5. package/.agents/CLI.md +195 -1
  6. package/.agents/ORM.md +88 -4
  7. package/.agents/PLUGINS.md +221 -9
  8. package/.agents/QUEUE.md +127 -0
  9. package/.agents/ROUTING.md +127 -7
  10. package/AGENTS.md +1 -0
  11. package/bin/commands/add-esbuild.js +80 -0
  12. package/bin/commands/add-tailwind.js +76 -31
  13. package/bin/commands/analytics-prune.js +57 -0
  14. package/bin/commands/api.js +207 -15
  15. package/bin/commands/build.js +99 -0
  16. package/bin/commands/dev.js +64 -35
  17. package/bin/commands/doctor.js +26 -0
  18. package/bin/commands/email-prune.js +47 -0
  19. package/bin/commands/make-job.js +86 -0
  20. package/bin/commands/make-model.js +250 -0
  21. package/bin/commands/new.js +45 -28
  22. package/bin/commands/openapi.js +102 -0
  23. package/bin/commands/page.js +6 -3
  24. package/bin/commands/queue-prune.js +59 -0
  25. package/bin/commands/routes.js +52 -0
  26. package/bin/commands/service.js +6 -3
  27. package/bin/commands/typegen.js +106 -0
  28. package/bin/commands/upload-prune.js +79 -0
  29. package/bin/webspresso.js +22 -0
  30. package/core/applySchema.js +13 -6
  31. package/core/auth/hash.js +21 -12
  32. package/core/auth/index.js +60 -0
  33. package/core/auth/manager.js +134 -0
  34. package/core/auth/middleware.js +88 -0
  35. package/core/auth/oauth.js +517 -0
  36. package/core/auth/qrcode.js +499 -0
  37. package/core/auth/totp.js +504 -0
  38. package/core/compression/middleware.js +23 -17
  39. package/core/openapi/build-from-api-routes.js +160 -29
  40. package/core/orm/cache/fingerprint.js +15 -1
  41. package/core/orm/cache/memory-provider.js +27 -2
  42. package/core/orm/eager-loader.js +144 -10
  43. package/core/orm/index.js +20 -7
  44. package/core/orm/model.js +53 -7
  45. package/core/orm/query-builder.js +90 -4
  46. package/core/orm/repository.js +44 -3
  47. package/core/orm/search.js +213 -0
  48. package/core/queue/adapters/database.js +94 -0
  49. package/core/queue/adapters/memory.js +62 -0
  50. package/core/queue/adapters/redis.js +80 -0
  51. package/core/queue/cron.js +331 -0
  52. package/core/queue/index.js +8 -1
  53. package/core/queue/manager.js +205 -1
  54. package/core/queue/scheduler.js +359 -0
  55. package/core/sse/index.js +19 -0
  56. package/core/sse/stream.js +430 -0
  57. package/core/ssr/stream.js +11 -0
  58. package/index.d.ts +1188 -10
  59. package/index.js +88 -96
  60. package/package.json +1 -1
  61. package/plugins/admin-panel/api.js +279 -17
  62. package/plugins/admin-panel/app.js +114 -22
  63. package/plugins/admin-panel/client/parts/01-state-api-breadcrumb.js +314 -9
  64. package/plugins/admin-panel/client/parts/04-field-renderers.js +264 -67
  65. package/plugins/admin-panel/client/parts/05-rich-text-file-helpers.js +333 -36
  66. package/plugins/admin-panel/client/parts/06-login-setup-forms.js +119 -28
  67. package/plugins/admin-panel/client/parts/07-model-list.js +49 -8
  68. package/plugins/admin-panel/client/parts/08-record-list.js +248 -23
  69. package/plugins/admin-panel/client/parts/09-record-form.js +59 -12
  70. package/plugins/admin-panel/client/parts/10-export-registry.js +23 -0
  71. package/plugins/admin-panel/client/verify-spa-parts.js +2 -0
  72. package/plugins/admin-panel/core/admin-module.js +13 -7
  73. package/plugins/admin-panel/core/api-extensions.js +30 -2
  74. package/plugins/admin-panel/core/registry.js +96 -16
  75. package/plugins/admin-panel/field-renderers/file-upload.js +187 -104
  76. package/plugins/admin-panel/field-renderers/index.js +2 -0
  77. package/plugins/admin-panel/index.js +96 -0
  78. package/plugins/admin-panel/lib/login-rate-limiter.js +119 -0
  79. package/plugins/admin-panel/modules/admin-users.js +16 -1
  80. package/plugins/admin-panel/modules/bulk-actions.js +13 -5
  81. package/plugins/admin-panel/modules/custom-pages.js +492 -3
  82. package/plugins/admin-panel/modules/dashboard.js +283 -0
  83. package/plugins/admin-panel/modules/menu.js +56 -18
  84. package/plugins/admin-panel/modules/user-management.js +24 -6
  85. package/plugins/api-key/crypto.js +101 -0
  86. package/plugins/api-key/index.js +288 -0
  87. package/plugins/api-key/middleware.js +461 -0
  88. package/plugins/api-key/migration-template.js +48 -0
  89. package/plugins/api-key/model.js +50 -0
  90. package/plugins/api-key/scopes.js +125 -0
  91. package/plugins/audit-log/admin-component.js +487 -59
  92. package/plugins/audit-log/api-handlers.js +148 -0
  93. package/plugins/audit-log/index.js +153 -3
  94. package/plugins/audit-log/purge.js +60 -10
  95. package/plugins/dashboard/app.js +152 -27
  96. package/plugins/dashboard/index.js +143 -39
  97. package/plugins/dashboard/styles.js +78 -0
  98. package/plugins/data-exchange/export-xlsx.js +9 -2
  99. package/plugins/data-exchange/import.js +9 -2
  100. package/plugins/data-exchange/index.js +6 -3
  101. package/plugins/email/index.js +132 -2
  102. package/plugins/email/purge.js +60 -10
  103. package/plugins/file-manager/admin-component.js +20 -4
  104. package/plugins/file-manager/index.js +21 -4
  105. package/plugins/index.js +10 -0
  106. package/plugins/oauth/index.js +338 -0
  107. package/plugins/orm-cache-admin/index.js +4 -2
  108. package/plugins/queue/index.js +15 -0
  109. package/plugins/schema-explorer/admin-component.js +714 -0
  110. package/plugins/schema-explorer/relations-graph.js +205 -0
  111. package/plugins/schema-explorer.js +248 -69
  112. package/plugins/site-analytics/admin-component.js +23 -0
  113. package/plugins/site-analytics/api-handlers.js +42 -0
  114. package/plugins/site-analytics/index.js +147 -2
  115. package/plugins/site-analytics/purge.js +168 -0
  116. package/plugins/swagger.js +261 -13
  117. package/plugins/upload/index.js +26 -0
  118. package/plugins/upload/orphan-pruner.js +292 -0
  119. package/src/api/api-loader.js +4 -1
  120. package/src/bundle/index.js +517 -0
  121. package/src/bundle/manifest.js +165 -0
  122. package/src/dev/banner.js +296 -0
  123. package/src/dev/colors.js +70 -0
  124. package/src/dev/index.js +62 -0
  125. package/src/dev/profiler.js +354 -0
  126. package/src/dev/typegen.js +497 -0
  127. package/src/dev-reload/index.js +611 -0
  128. package/src/discovery/file-route-parser.js +44 -1
  129. package/src/discovery/scan-routes.js +2 -2
  130. package/src/file-router.js +347 -162
  131. package/src/helpers.js +967 -98
  132. package/src/njk-frontmatter.js +12 -0
  133. package/src/pages/page-loader.js +61 -11
  134. package/src/routing/cascading.js +378 -0
  135. package/src/routing/middleware-resolver.js +92 -0
  136. package/src/server.js +635 -63
@@ -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
  };
@@ -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 { loadI18n, createTranslator, detectLocale, resolveMiddlewares } = require('../file-router');
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
- const translations = loadI18n(pagesDir, '', locale, isDev);
115
- const fallbackTranslations = locale !== defaultLocale ? loadI18n(pagesDir, '', defaultLocale, isDev) : undefined;
116
- const t = createTranslator(translations, {
117
- locale,
118
- fallbackTranslations,
119
- fallbackLocale: defaultLocale,
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: headData,
169
- pageHead: headData,
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
+ };